2장. 검색 증강 생성 기초와 실습
출처: 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(브라이스 유·조경아·박수진·김재웅, 프리렉 2025) | 공식: https://www.langchain.com/
이 장은 RAG의 핵심 빌딩 블록을 처음부터 직접 조립하는 장이다. 텍스트를 벡터로 바꾸고, 문서를 로드해 청크로 나누고, 벡터 DB에 저장한 뒤 검색기로 연결해 실제 동작하는 RAG 챗봇을 완성한다.
실습 — 책 공식 노트북: Ch02. RAG 폴더 — Document Loader·Embedding·RAG Chatbot 4개. 코랩 바로 열기: Document Loader·Embedding·RAG Chatbot 종합.
학습 목표
이 장을 끝내면 다음을 할 수 있다.
- RAG 파이프라인의 인덱싱 단계(로드·분할·임베딩·저장)와 쿼리 단계(검색·생성)를 구분하고 각 단계의 역할을 설명한다.
- 워드·문장·문서 임베딩의 차이를 설명하고 코사인 유사도를 직접 계산한다.
WebBaseLoader·PyPDFLoader·CSVLoader등 문서 로더를 소스 유형에 맞게 선택하고Document객체로 변환한다.RecursiveCharacterTextSplitter와SemanticChunker의 분할 기준을 비교하고,chunk_size·chunk_overlap파라미터를 요구사항에 맞게 조정한다.- Chroma 또는 FAISS 벡터스토어에 임베딩을 저장하고
as_retriever()로 검색기를 생성한다. - LCEL 체인(
retriever | prompt | llm | parser)을 조립해 대화 이력을 유지하는 RAG 챗봇을 구현한다. - Streamlit으로 RAG 챗봇 웹 UI를 만들고
@st.cache_resource로 무거운 초기화를 캐싱한다.
전체 흐름도
[문서]
↓ (1) 로드 — DocumentLoader (웹/PDF/CSV)
[원문 텍스트]
↓ (2) 분할 — TextSplitter (RecursiveChar / SemanticChunker)
[청크 리스트]
↓ (3) 임베딩 — OpenAIEmbeddings / HuggingFaceEmbeddings
[벡터 리스트]
↓ (4) 저장 — VectorStore (Chroma / FAISS)
[인덱스]
↑ (5) 검색 — as_retriever() → 유사도 상위 k개
[관련 청크]
↓ (6) 생성 — retriever | prompt | llm | parser
[최종 답변]
인덱싱(1~4)은 사전 준비 단계, 쿼리(5~6)는 사용자 질문 처리 단계다.
0. 사전 필수 용어
- 임베딩(Embedding) — 텍스트를 의미를 담은 실수 벡터로 변환하는 과정. 예:
"안녕"→[-0.01, 0.03, ...]. - 코사인 유사도(Cosine Similarity) — 두 벡터의 방향 유사성(-1~1). 값이 1에 가까울수록 의미가 비슷하다.
- 코사인 거리(Cosine Distance) —
1 - 코사인 유사도. 0에 가까울수록 두 문장이 유사하다. - 청크(Chunk) — 긴 문서를 일정 크기로 잘라낸 조각. 너무 작으면 정보 부족, 너무 크면 검색 정확도 하락.
- 벡터스토어(VectorStore) — 임베딩을 저장하고 유사도 검색을 수행하는 데이터베이스. Chroma·FAISS 등.
- 리트리버(Retriever) — 벡터스토어에서
as_retriever()로 생성한 검색 도구. 질문 입력 → 유사 청크 반환. - 문서 로더(DocumentLoader) — 웹·PDF·CSV 등 다양한 소스를 랭체인
Document객체로 변환하는 컴포넌트. - chunk_size / chunk_overlap — 분할기 파라미터. size=최대 문자 수, overlap=연속 청크 간 중복 문자 수.
- 시맨틱 청킹(Semantic Chunking) — 단순 길이가 아닌 문장 간 의미 유사도 변화 지점을 기준으로 분할하는 기법.
1. 검색 증강 생성 개요
RAG는 LLM의 생성 능력에 외부 정보 검색을 결합한다. 모델 재학습(파인튜닝) 없이 최신·도메인 데이터를 반영할 수 있어 실무에서 가장 널리 쓰이는 LLM 활용 패턴이다. 파인튜닝은 비용이 많이 들고 정보 신뢰성 보장이 어렵지만, RAG는 검색된 정보를 실시간으로 모델에 제공해 정확성과 관련성을 높인다.
1.1 텍스트 임베딩
임베딩은 '의미를 좌표로 변환'하는 과정이다. 인공지능 모델은 텍스트를 직접 처리하지 않고, 벡터로 변환(임베딩)하여 분석한다.
임베딩의 종류:
- 워드 임베딩(Word Embedding) — 단어 단위 벡터. 예: "랭체인" → [-0.01, -0.02, 0.03, 0.12]
- 문장 임베딩(Sentence Embedding) — 문장 단위 벡터. 예: "안녕하세요" → [-0.01, -0.05, -0.16, 0.19]
- 문서 임베딩(Document Embedding) — 문서 단위 벡터
비유 — 지도 위 좌표처럼, 비슷한 의미의 단어는 벡터 공간에서 가까운 위치에 놓인다. "강아지"와 "개"는 가깝고, "강아지"와 "자동차"는 멀다.
1.2 코사인 유사도
두 벡터 A, B의 코사인 유사도는 벡터의 크기가 아닌 방향을 기준으로 유사성을 측정한다.
similarity = cos(θ) = (A · B) / (‖A‖ × ‖B‖)
- 1.0 → 완전히 같은 방향 (매우 유사)
- 0.0 → 직각 (무관)
- -1.0 → 반대 방향 (반의어 수준)
실무 포인트 — 문장 임베딩 모델은 양수 벡터만 생성하므로 실제로는 0~1 범위 값이 나온다. 코사인 거리(
1 - 유사도)로 변환하면 "얼마나 다른가"를 직관적으로 나타낼 수 있다. SemanticChunker는 코사인 거리를 분할 기준으로 사용한다.
import numpy as np
from numpy import dot
from numpy.linalg import norm
def cos_sim(A, B):
"""코사인 유사도 계산."""
return dot(A, B) / (norm(A) * norm(B))
vec1 = np.array([0, 1, 1, 1])
vec2 = np.array([1, 0, 2, 1])
vec3 = np.array([2, 0, 4, 2]) # vec2의 2배 → 방향 동일, 유사도 1.0
print(cos_sim(vec1, vec2)) # 0.707...
print(cos_sim(vec1, vec3)) # 0.707... (방향 동일하지 않음)
print(cos_sim(vec2, vec3)) # 1.000 (방향 동일)
1.3 랭체인 임베딩 API 활용
랭체인에서 사용할 수 있는 임베딩은 크게 두 유형이다.
| 유형 | 대표 모델 | 특징 |
|---|---|---|
| LLM 제공사 | text-embedding-3-small/large (OpenAI), Cohere, MistralAI |
API 호출, 유료, 고품질 |
| 허깅페이스 오픈 모델 | BAAI/bge-m3, nip lab/KoE5 |
로컬 실행, 무료, 한국어 특화 가능 |
import os
from dotenv import load_dotenv
import numpy as np
from numpy import dot
from numpy.linalg import norm
import pandas as pd
from langchain_openai import OpenAIEmbeddings
load_dotenv()
# OpenAI 임베딩 초기화 (text-embedding-3-small: 1536차원)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# 단일 쿼리 임베딩
query_vector = embeddings.embed_query("저는 배가 고파요")
print(f"벡터 차원: {len(query_vector)}") # 1536
# 문장들 임베딩 후 유사도 검색
data = ["주식 시장이 급등했어요", "시장 물가가 올랐어요",
"전통 시장에는 다양한 물품들을 팔아요", "저는 빠른 비트를 좋아해요",
"최근 비트코인 가격이 많이 변동했어요"]
df = pd.DataFrame(data, columns=["text"])
df["embedding"] = df["text"].apply(lambda t: embeddings.embed_query(t))
def cos_sim(A, B):
return dot(A, B) / (norm(A) * norm(B))
def search(df, query, top_k=3):
"""쿼리와 가장 유사한 문서 반환."""
q_vec = embeddings.embed_query(query)
df["similarity"] = df["embedding"].apply(lambda x: cos_sim(np.array(x), q_vec))
return df.sort_values("similarity", ascending=False).head(top_k)
result = search(df, "과일 값이 비싸다")
print(result[["text", "similarity"]])
# 결과: '전통 시장', '시장 물가', '주식 시장' 순으로 유사도 높음
참고 — 허깅페이스 모델(
BAAI/bge-m3)은from langchain_community.embeddings import HuggingFaceBgeEmbeddings로 로컬 무료 실행 가능. 단, 키워드 유사성에 강하지만 복잡한 맥락 유사성 파악에는 OpenAI 대비 한계가 있을 수 있다.
2. 문서 로더
랭체인의 문서 로더는 80종 이상의 소스를 Document(page_content=..., metadata=...) 표준 형식으로 변환한다. 모든 로더의 출력 형태가 같아서 이후 분할·임베딩 단계를 소스 종류에 무관하게 재사용할 수 있다.
주요 로더 목록:
- WebBaseLoader — 웹 페이지 텍스트 추출 (BeautifulSoup)
- PyPDFLoader — PDF 페이지 단위 로드
- PyMuPDFLoader — 빠른 PDF 로드, 풍부한 메타데이터
- PDFPlumberLoader — 표·구조화 데이터 특화
- CSVLoader — CSV 행 단위 로드
- UnstructuredCSVLoader — CSV 전체를 HTML 표로 변환 가능
2.1 웹 페이지 로더 — WebBaseLoader
HTML을 파싱(BeautifulSoup)해 텍스트를 추출한다. 단일·다중 URL 모두 지원하며, 비동기/지연 로딩으로 성능을 높일 수 있다.
import os
from langchain_community.document_loaders import WebBaseLoader
os.environ["USER_AGENT"] = "MyRAGApp/1.0" # 서버 식별용, 미설정 시 경고
# 단일 URL
loader = WebBaseLoader("https://docs.smith.langchain.com/")
single_doc = loader.load()
print(single_doc[0].metadata)
# {'source': 'https://...', 'title': '...', 'language': 'en'}
# 다중 URL
loader_multi = WebBaseLoader([
"https://python.langchain.com/docs/introduction/",
"https://langchain-ai.github.io/langgraph/",
])
docs = loader_multi.load()
print(docs[0].page_content[:200])
2.2 PDF 로더
로더 선택 기준:
| 로더 | 속도 | 메타데이터 | 적합 상황 |
|---|---|---|---|
PyPDFLoader |
보통(~24초) | 기본(source, page) | 간단·표준 PDF |
PyMuPDFLoader |
빠름(~2.6초) | 풍부(포맷·작성자·total_pages 등) | 대용량·다수 파일 |
PDFPlumberLoader |
느림(~39초) | 풍부(HTML 표 포함) | 표·구조화 데이터 추출 |
from langchain_community.document_loaders import PyPDFLoader, PyMuPDFLoader
# PyPDFLoader: 기본 메타데이터 {source, page}
loader = PyPDFLoader("경로/파일.pdf")
pages = loader.load_and_split() # 로드 + 기본 분할기 자동 적용
print(f"청크의 수: {len(pages)}")
# Document(metadata={'source': '파일.pdf', 'page': 0}, page_content='본문')
# PyMuPDFLoader: 더 빠르고 풍부한 메타데이터 (file_path, total_pages, format, author 등)
loader_mu = PyMuPDFLoader("경로/파일.pdf")
pages_mu = loader_mu.load_and_split()
참고 —
load_and_split()은 로드 후 기본 분할기(RecursiveCharacterTextSplitter)로 분할까지 자동 수행한다. 분할 파라미터를 직접 제어하려면load()+ 별도TextSplitter를 사용한다. 이미지만 있거나 빈 페이지는 자동 제외되어 실제 청크 수 < 전체 페이지 수가 될 수 있다.
2.3 CSV 로더
| 로더 | 방식 | 청크 수 | 특징 |
|---|---|---|---|
CSVLoader |
행 단위 분리 | 행 수와 동일 | 빠름(151ms), 빈 행도 청크로 처리 |
UnstructuredCSVLoader |
single/elements 모드 | 1개(single/elements) | text_as_html 키에 HTML 표 구조 포함 |
from langchain_community.document_loaders import CSVLoader, UnstructuredCSVLoader
# CSVLoader — 기본, 행 단위
loader_csv = CSVLoader("경로/데이터.csv")
documents = loader_csv.load()
# Document(metadata={'source': 파일명, 'row': 행번호}, page_content='열:값\n...')
# UnstructuredCSVLoader — elements 모드 (HTML 표 포함)
loader_unstructured = UnstructuredCSVLoader("경로/데이터.csv", mode="elements")
docs_u = loader_unstructured.load()
# metadata에 text_as_html로 <table>...</table> 구조 포함
print(docs_u[0].metadata.get("text_as_html", "")[:300])
3. 텍스트 분할
LLM은 입력 길이(컨텍스트 윈도우)에 제한이 있다. 긴 문서를 그대로 넣으면 토큰을 초과하거나 검색 정확도가 떨어진다. 분할은 모든 RAG 파이프라인의 핵심 전처리 단계다. 청크가 너무 작으면 정보 부족, 너무 크면 검색 정확도 하락이 발생한다.
3.1 재귀적 문자 텍스트 분할 — RecursiveCharacterTextSplitter
가장 널리 쓰이는 분할기. 기본 구분자 ["\n\n", "\n", " ", ""] 순서로 시도하면서, 지정한 chunk_size(최대 문자 수)를 넘지 않을 때까지 반복 분할한다.
비유 — 길고 긴 글을 먼저 단락 단위로 자르고, 아직 크면 줄 단위로, 그래도 크면 공백 단위로 잘라낸다.
핵심 파라미터:
- chunk_size — 각 청크의 최대 문자 수 (예: 500, 1000)
- chunk_overlap — 인접 청크 간 중복 문자 수. 문맥 연속성을 위해 50~200 권장
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader
loader = PyPDFLoader("경로/파일.pdf")
pages = loader.load()
print(f"원본 페이지 수: {len(pages)}") # 예: 84페이지
# 500자 크기, 50자 겹침으로 분할
text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
texts = text_splitter.split_documents(pages)
print(f"분할된 청크 수: {len(texts)}") # 예: 242개
# 청크 길이 분포 확인
lengths = [len(c.page_content) for c in texts]
print(f"최대: {max(lengths)}, 최소: {min(lengths)}, 평균: {sum(lengths)/len(lengths):.0f}")
# 단순 문자열 분할 시
raw_text = "아주 긴 텍스트 내용..."
chunks = text_splitter.split_text(raw_text)
실무 포인트 — chunk_overlap이 50으로 설정되면 1번 청크의 마지막 50자가 2번 청크의 앞부분에도 등장한다. 이 덕분에 청크 경계에서 문맥이 잘리는 문제를 줄일 수 있다.
split_documents()는 Document 객체 리스트 입력,split_text()는 단순 문자열 입력.
3.2 시맨틱 청킹 — SemanticChunker
단순 길이 기준이 아닌 문장 임베딩의 코사인 거리를 기반으로, 의미가 크게 달라지는 지점에서 분할한다. 청크마다 한 가지 주제가 모이도록 보장해 RAG 정확도를 높인다.
SemanticChunker 내부 동작: 1. 문장을 임베딩 벡터로 변환 2. 인접 문장 쌍 간 코사인 거리(= 1 - 코사인 유사도) 계산 3. 선택한 방식(백분위수/표준편차/사분위수)에 따라 분할 지점 결정 4. 의미 변화가 큰 지점에서 청크 분리
분할 방식 3가지:
| 방식 | breakpoint_threshold_type |
특징 | 청크 수 예시 |
|---|---|---|---|
| 백분위수(기본) | "percentile" |
상위 X% 차이 지점에서 분할. 극단적 주제 전환 감지 | 165개 |
| 표준편차 | "standard_deviation" |
평균 코사인 거리 + N×표준편차 초과 지점에서 분할 | 84개(더 적음) |
| 사분위수 | "interquartile" |
중간 50% 분포 기준 이상치 지점에서 분할. 이상치에 덜 민감 | 145개 |
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
from langchain_community.document_loaders import PyPDFLoader
loader = PyPDFLoader("경로/파일.pdf")
pages = loader.load()
# 기본(백분위수 95): 상위 5% 의미 차이 지점에서만 분할
text_splitter = SemanticChunker(
embeddings=OpenAIEmbeddings(),
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=95,
)
chunks = text_splitter.split_documents(pages)
print(f"백분위수 청크 수: {len(chunks)}") # 165
# 표준편차 방식: 평균 + 3σ 초과 지점에서 분할 → 더 적은 청크
text_splitter_std = SemanticChunker(
OpenAIEmbeddings(),
breakpoint_threshold_type="standard_deviation",
breakpoint_threshold_amount=3,
)
chunks_std = text_splitter_std.split_documents(pages)
print(f"표준편차 청크 수: {len(chunks_std)}") # 84
# 사분위수 방식: Q3 + 1.5×IQR 초과 지점에서 분할
text_splitter_iq = SemanticChunker(
OpenAIEmbeddings(),
breakpoint_threshold_type="interquartile",
breakpoint_threshold_amount=1.5,
)
chunks_iq = text_splitter_iq.split_documents(pages)
print(f"사분위수 청크 수: {len(chunks_iq)}") # 145
참고 — SemanticChunker는 내부적으로 임베딩 API를 호출하므로 RecursiveCharacterTextSplitter보다 비용이 더 든다.
breakpoint_threshold_amount값을 낮추면 청크가 많아지고(짧아짐), 높이면 청크가 적어진다(길어짐).
4. 벡터 데이터베이스
판다스+넘파이 방식은 작은 예제에만 적합하다. 실제 운영에서는 임베딩을 효율적으로 저장하고 빠르게 검색하는 벡터 데이터베이스를 쓴다.
주요 벡터 DB 비교:
| DB | 특징 | 적합 상황 |
|---|---|---|
| Chroma | 오픈소스, 단순 인터페이스, 빠른 프로토타이핑 | 개발·실험·소규모 |
| FAISS | 메타(Facebook AI) 개발, GPU 가속, 고속 대규모 검색 | 수백만 벡터, 프로덕션 |
| Pinecone | 관리형 클라우드, 인증·백업 지원 | 서버리스·엔터프라이즈 |
| Qdrant | 자체 호스팅, 고급 필터링 | 복잡한 메타 필터 필요 |
| Milvus | 분산 아키텍처, 다양한 메트릭 | 초대규모 분산 환경 |
| Elasticsearch | 코사인·내적·L1·L2 지원 | 기존 검색 인프라 통합 |
4.1 크로마 — Chroma
오픈소스, 단일 노드, 빠른 프로토타이핑에 최적화. persist_directory를 지정하면 chroma.sqlite3에 영구 저장된다. 미설정 시 메모리에만 저장되어 세션 종료 시 삭제됨.
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
# 1. 문서 로드 및 분할
loader = PyPDFLoader("경로/파일.pdf")
pages = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
splits = text_splitter.split_documents(pages)
print(f"분할 청크 수: {len(splits)}") # 예: 138
# 2. 임베딩 + Chroma 저장 (from_documents로 원스텝)
embedding_fn = OpenAIEmbeddings()
persist_dir = "./chroma_db"
vectordb = Chroma.from_documents(
documents=splits,
embedding=embedding_fn,
persist_directory=persist_dir,
)
print(f"저장된 문서 수: {vectordb._collection.count()}")
# 주의: from_documents() 반복 호출 시 중복 쌓임 → 주의
# 3. 기존 DB 불러오기
vectordb = Chroma(
embedding_function=embedding_fn,
persist_directory=persist_dir,
)
# 4. 유사도 검색
question = "수도권 주택 매매 전망"
top_docs = vectordb.similarity_search(question, k=3)
for i, doc in enumerate(top_docs, 1):
print(f"[{i}] {doc.page_content[:100]}...")
# 유사도 점수와 함께 검색 (기본 k=4, k 지정 가능)
scored_docs = vectordb.similarity_search_with_relevance_scores(question, k=3)
for doc, score in scored_docs:
print(f"점수: {score:.4f} | {doc.page_content[:80]}...")
4.2 파이스 — FAISS
메타(Facebook AI) 개발. GPU 가속과 대규모 인덱싱 알고리즘으로 수백만 벡터를 빠르게 검색한다. 분산 환경 지원은 제한적.
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
# Chroma와 동일한 splits, embedding_fn 재사용
faiss_db = FAISS.from_documents(documents=splits, embedding=embedding_fn)
print(f"저장된 문서 수: {faiss_db.index.ntotal}") # Chroma의 _collection.count()와 다름
# 로컬 저장 / 불러오기 (Chroma와 다름: save_local / load_local)
faiss_db.save_local("./faiss_index")
loaded_db = FAISS.load_local(
"./faiss_index",
OpenAIEmbeddings(),
allow_dangerous_deserialization=True, # 본인이 저장한 파일만 True 설정
)
# 유사도 검색 (Chroma와 동일 인터페이스)
docs = loaded_db.similarity_search("수도권 주택 매매 전망", k=3)
# 검색기(Retriever)로 변환 — RAG 체인에서 사용 (Chroma·FAISS 동일)
retriever = loaded_db.as_retriever(search_kwargs={"k": 3})
참고 —
as_retriever()는 Chroma·FAISS 모두 동일하게 사용한다. 한번 retriever를 만들면 벡터DB 종류에 상관없이 체인에 꽂을 수 있다.
5. RAG 챗봇 실습
앞에서 만든 빌딩 블록(로더 → 분할기 → 임베딩 → 벡터DB → 검색기)을 LCEL 체인으로 연결해 실제 동작하는 챗봇을 완성한다.
5.1 RAG 챗봇 구현
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough, RunnableWithMessageHistory
from langchain_community.chat_message_histories import ChatMessageHistory
# ── 인덱싱 과정 ──────────────────────────────────────────
# 1. 문서 로드 및 분할
loader = PyPDFLoader("경로/파일.pdf")
documents = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
chunks = text_splitter.split_documents(documents)
# 2. 임베딩 + 벡터 DB 저장
embedding_fn = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embedding_fn,
persist_directory="./chroma_db",
)
# ── 쿼리 과정 ─────────────────────────────────────────────
# 3. 리트리버 생성 (유사 청크 상위 3개)
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
# 4. 프롬프트 + 모델 설정
template = """당신은 문서 전문가입니다. 다음 정보를 바탕으로 질문에 답변해주세요.
컨텍스트: {context}"""
prompt = ChatPromptTemplate.from_messages([
("system", template),
("placeholder", "{chat_history}"),
("human", "{question}"),
])
model = ChatOpenAI(model_name="gpt-4o-mini", temperature=0)
# 5. 문서 포맷 함수 + 체인 조립
def format_docs(docs):
"""검색된 청크들을 하나의 컨텍스트 문자열로 합침."""
return "\n\n".join(doc.page_content for doc in docs)
chain = (
RunnablePassthrough.assign(
context=lambda x: format_docs(retriever.invoke(x["question"]))
)
| prompt
| model
| StrOutputParser()
)
# 6. 메모리 설정 — 세션별 대화 이력 자동 관리
chat_history = ChatMessageHistory()
chain_with_memory = RunnableWithMessageHistory(
chain,
lambda session_id: chat_history,
input_messages_key="question",
history_messages_key="chat_history",
)
# 7. 챗봇 실행
def run_chatbot():
session_id = "user_session_1"
print("챗봇이 시작되었습니다. 종료는 'quit'")
while True:
user_input = input("사용자: ")
if user_input.lower() == "quit":
break
response = chain_with_memory.invoke(
{"question": user_input},
{"configurable": {"session_id": session_id}},
)
print(f"챗봇: {response}")
5.2 스트림릿 적용
스트림릿(Streamlit)은 순수 파이썬 코드만으로 대화형 웹 UI를 만드는 라이브러리다. HTML·CSS 지식 없이 챗봇 인터페이스를 빠르게 붙일 수 있다.
핵심 스트림릿 패턴:
- @st.cache_resource — PDF 로드·벡터DB 초기화처럼 무거운 연산을 한 번만 실행하고 캐싱. 미사용 시 매 요청마다 재실행됨
- st.session_state.messages — 페이지 새로고침 후에도 대화 이력 유지
- st.chat_input / st.chat_message — 채팅 인터페이스 구성
- st.spinner — 응답 생성 중 로딩 표시
# app.py (Streamlit 챗봇)
import streamlit as st
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough, RunnableWithMessageHistory
from langchain_community.chat_message_histories import ChatMessageHistory
@st.cache_resource
def build_chain():
"""PDF 처리부터 체인 생성까지 한 번만 실행, 이후 캐싱."""
loader = PyPDFLoader("경로/파일.pdf")
docs = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
chunks = splitter.split_documents(docs)
vectorstore = Chroma.from_documents(chunks, OpenAIEmbeddings())
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
template = "당신은 전문가입니다.\n컨텍스트: {context}"
prompt = ChatPromptTemplate.from_messages([
("system", template),
("placeholder", "{chat_history}"),
("human", "{question}"),
])
model = ChatOpenAI(model_name="gpt-4o-mini", temperature=0)
def format_docs(docs):
return "\n\n".join(d.page_content for d in docs)
base_chain = (
RunnablePassthrough.assign(
context=lambda x: format_docs(retriever.invoke(x["question"]))
)
| prompt | model | StrOutputParser()
)
return RunnableWithMessageHistory(
base_chain,
lambda session_id: ChatMessageHistory(),
input_messages_key="question",
history_messages_key="chat_history",
)
def main():
st.set_page_config(page_title="RAG 챗봇")
st.title("RAG 챗봇")
if "messages" not in st.session_state:
st.session_state.messages = []
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
if user_input := st.chat_input("질문을 입력하세요"):
with st.chat_message("user"):
st.markdown(user_input)
st.session_state.messages.append({"role": "user", "content": user_input})
chain = build_chain()
with st.chat_message("assistant"):
with st.spinner("답변 생성 중..."):
response = chain.invoke(
{"question": user_input},
{"configurable": {"session_id": "streamlit_session"}},
)
st.markdown(response)
st.session_state.messages.append({"role": "assistant", "content": response})
if __name__ == "__main__":
main()
실행 방법:
streamlit run app.py
# 구글 코랩에서 외부 노출 시:
# pip install streamlit pyngrok
# !streamlit run app.py &
# from pyngrok import ngrok; print(ngrok.connect(8501))
핵심 개념 정리
| 개념 | 한 줄 요약 |
|---|---|
| 임베딩 | 텍스트 → 의미 벡터. 유사한 의미는 벡터 공간에서 가깝다 |
| 코사인 유사도 | 벡터 방향 유사성(0~1). RAG 검색의 핵심 수학 |
| 코사인 거리 | 1 - 코사인 유사도. SemanticChunker의 분할 기준 |
| 문서 로더 | 웹/PDF/CSV → 표준 Document 객체. 80종+ 지원 |
| RecursiveCharacterTextSplitter | 구분자 계층 순으로 반복 분할. 빠르고 실용적 |
| SemanticChunker | 의미 변화 지점에서 분할. 청크 품질↑, 비용↑ |
| Chroma / FAISS | 벡터 저장·검색 DB. as_retriever()로 체인에 연결 |
| RAG 체인 | retriever | prompt | llm | parser LCEL 조립 |
| Streamlit | 파이썬만으로 대화형 웹 UI 구성, @st.cache_resource로 무거운 초기화 캐싱 |
한 문장 요약 — 2장은 RAG의 전체 파이프라인(로드 → 분할 → 임베딩 → 저장 → 검색 → 생성)을 직접 조립하며, 임베딩·코사인 유사도·문서 로더·텍스트 분할기·벡터 DB의 원리와 실습을 한 번에 익히는 장이다.
실무 체크리스트
- [ ] 문서 로더 선택 기준: 단순 텍스트 → PyPDFLoader, 속도·메타데이터 중요 → PyMuPDFLoader, 표 구조 → PDFPlumberLoader.
- [ ]
chunk_size=1000, chunk_overlap=200을 기본값으로 시작하고, RAG 정확도를 측정해 조정한다. - [ ] Chroma는 프로토타이핑·소규모에, FAISS는 수십만 벡터 이상 프로덕션에 고려한다.
- [ ]
as_retriever(search_kwargs={"k": 3})로 반환 문서 수를 명시한다. 기본 4개는 토큰 낭비일 수 있다. - [ ] 스트림릿 앱은
@st.cache_resource로 PDF 로드·벡터DB 초기화를 반드시 캐싱한다 (매 요청마다 재실행 방지). - [ ] Chroma
from_documents()반복 호출 시 중복 쌓임 주의. 기존 DB 재사용 시Chroma(persist_directory=...)사용. - [ ] FAISS
load_local()시allow_dangerous_deserialization=True는 본인이 저장한 파일임을 확인 후에만 설정. - [ ] 모델명(
gpt-4o-mini)·임베딩 모델(text-embedding-3-small)은 공식 문서에서 현행 모델명을 확인 후 사용한다.
연습문제
- 개념. RAG에서 '인덱싱 과정'과 '쿼리 과정'의 차이를 각각 한 문장으로 설명하라.
- 비교.
RecursiveCharacterTextSplitter와SemanticChunker의 분할 기준 차이를 설명하고, 언제 SemanticChunker를 선택하는 게 좋은지 이유를 들어라. - 코드.
Chroma.from_documents()와FAISS.from_documents()의 저장/로드 방식 차이를 코드 수준에서 설명하라 (persist_directory vs save_local, _collection.count() vs index.ntotal). - 적용. PDF 기반 RAG 챗봇에서 chunk_size=200으로 너무 작게 설정했을 때 발생할 수 있는 문제를 설명하고, 적절한 값 선택 기준을 제시하라.
- 설계. 수십만 건의 뉴스 기사를 색인해 실시간 검색을 제공하는 서비스를 만든다면 Chroma와 FAISS 중 무엇을 선택할지, 트레이드오프와 함께 논하라.
- 디버깅. 스트림릿 RAG 앱에서 질문할 때마다 PDF 재로드·벡터DB 재초기화가 발생한다면 무엇이 문제이고 어떻게 수정하는가?
최신 동향 (2026-05 기준)
최신 동향 (검증 2026-05-21) — 책(2025년, LangChain 0.3 기준) 이후 변화만 보완.
- OpenAI 임베딩 현행 모델 —
text-embedding-ada-002대신text-embedding-3-small/-large가 현행이다. 3세대 모델은dimensions파라미터로 벡터 차원을 축소해 저장 비용을 줄일 수 있다(예: 1536 → 256). 한국어 검색 품질도 개선됐다. (출처: https://platform.openai.com/docs/guides/embeddings) - 한국어 임베딩 대안 — OpenAI 외에 다국어
sentence-transformers모델(예:paraphrase-multilingual-mpnet-base-v2)이나 Qwen 임베딩도 한국어 RAG에 실용적인 대안으로 부상했다. - 벡터 DB 생태계 성숙 — Chroma·FAISS 외에 Qdrant가 로컬/클라우드 모두 지원하며 고급 메타데이터 필터링이 강력해 실무 채택이 늘었다.
- 메모리 관리 — 책의
RunnableWithMessageHistory는 LangChain 0.3에서 deprecated 방향이며, 대화 상태는 LangGraph persistence(checkpointer)로 관리하는 게 현재 권장이다. (출처: https://python.langchain.com/docs/versions/migrating_memory/)
부록 A. 용어 사전
| 한글 | 영문 | 의미 |
|---|---|---|
| 임베딩 | Embedding | 텍스트를 의미를 담은 실수 벡터로 변환하는 과정 |
| 코사인 유사도 | Cosine Similarity | 두 벡터의 방향 유사성(-1~1). 값이 1에 가까울수록 의미가 비슷하다 |
| 코사인 거리 | Cosine Distance | 1 - 코사인 유사도. 0에 가까울수록 유사, SemanticChunker 분할 기준 |
| 청크 | Chunk | 긴 문서를 일정 크기로 잘라낸 조각 |
| 벡터스토어 | VectorStore | 임베딩을 저장하고 유사도 검색을 수행하는 데이터베이스 |
| 리트리버 | Retriever | 벡터스토어에서 생성한 검색 도구. 질문 입력 → 유사 청크 반환 |
| 문서 로더 | DocumentLoader | 다양한 소스를 랭체인 Document 객체로 변환하는 컴포넌트 |
| 시맨틱 청킹 | Semantic Chunking | 문장 간 의미 유사도 변화 지점을 기준으로 분할하는 기법 |
| 청크 중복 | Chunk Overlap | 인접 청크 간 공유하는 문자 수. 경계에서 문맥 단절을 완화한다 |
| LCEL | LangChain Expression Language | | 연산자로 체인 컴포넌트를 선언적으로 조립하는 랭체인 문법 |
| RAG | Retrieval-Augmented Generation | 외부 문서 검색 결과를 LLM 생성에 결합하는 아키텍처 패턴 |
부록 B. 핵심 비교표
문서 로더 선택 기준
| 로더 | 속도 | 메타데이터 | 적합 상황 |
|---|---|---|---|
PyPDFLoader |
보통 | 기본(source, page) | 간단·표준 PDF |
PyMuPDFLoader |
빠름 | 풍부(포맷·작성자 등) | 대용량·다수 파일 |
PDFPlumberLoader |
느림 | 풍부(HTML 표 포함) | 표·구조화 데이터 추출 |
WebBaseLoader |
네트워크 의존 | source·title·language | 웹 페이지 텍스트 추출 |
CSVLoader |
빠름 | source·row 번호 | 단순 행 단위 처리 |
UnstructuredCSVLoader |
보통 | HTML 표 포함 | 표 구조 보존 필요 시 |
청킹 방식 비교
| 구분 | RecursiveCharacterTextSplitter | SemanticChunker |
|---|---|---|
| 분할 기준 | 구분자 계층(\n\n → \n → 공백) |
문장 임베딩 코사인 거리 |
| 비용 | 무료(추가 API 호출 없음) | 임베딩 API 호출 비용 발생 |
| 청크 품질 | 길이 기반, 주제 혼재 가능 | 한 청크 = 한 주제 보장 |
| 속도 | 빠름 | 느림(문장별 임베딩) |
| 적합 상황 | 빠른 프로토타이핑·대용량 | RAG 정확도가 핵심인 운영 서비스 |
벡터 DB 비교
| 구분 | Chroma | FAISS |
|---|---|---|
| 개발 주체 | Chroma 오픈소스 | Meta(Facebook AI) |
| 저장 방식 | persist_directory (SQLite) |
save_local() (파일) |
| 문서 수 확인 | _collection.count() |
index.ntotal |
| 규모 | 소~중규모, 프로토타이핑 | 수백만 벡터, 프로덕션 |
| GPU 가속 | 미지원 | 지원 |
| 분산 | 미지원 | 제한적 |
부록 C. 추천 참고 자료
검증된 외부 자료 (Tier 1 공식, 생존 확인 2026-05-21)
| 자료 | 링크 |
|---|---|
| OpenAI 임베딩 가이드 | platform.openai.com/docs/guides/embeddings |
| LangChain 텍스트 분할 개념 | python.langchain.com/docs/concepts/text_splitters |
| RecursiveCharacterTextSplitter how-to | python.langchain.com/docs/how_to/recursive_text_splitter |
| Chroma 벡터스토어 통합 | python.langchain.com/docs/integrations/vectorstores/chroma |
| FAISS 벡터스토어 통합 | python.langchain.com/docs/integrations/vectorstores/faiss |
| 문서 로더 전체 목록 | python.langchain.com/docs/integrations/document_loaders |
| 메모리 마이그레이션 가이드 | python.langchain.com/docs/versions/migrating_memory |
| 실습 코드(깃허브) | github.com/langchain-kr/langchain-tutorial (Ch02) |
| 자료 | 설명 |
|---|---|
| 책 3장 | 멀티모달 RAG — 이미지·PDF 복합 처리로 확장 |
부록 D. 연습문제 풀이
-
(인덱싱은 사전 준비, 쿼리는 질문 처리) 인덱싱 과정(1~4단계)은 문서를 로드·분할·임베딩·저장하는 사전 준비 단계로, 검색 가능한 벡터 인덱스를 구성한다. 쿼리 과정(5~6단계)은 사용자 질문이 들어올 때마다 실행되며, 벡터 인덱스에서 유사 청크를 검색한 뒤 LLM이 최종 답변을 생성한다.
-
(분할 기준: 길이 계층 vs 의미 거리)
RecursiveCharacterTextSplitter는["\n\n", "\n", " ", ""]구분자 계층을 순서대로 시도해chunk_size를 넘지 않을 때까지 반복 분할하므로 추가 API 비용이 없고 빠르다.SemanticChunker는 인접 문장 임베딩의 코사인 거리를 계산해 의미가 크게 달라지는 지점에서 분할하므로 한 청크에 하나의 주제가 모인다. RAG 검색 정확도가 서비스 품질에 직결되고 임베딩 API 비용을 감당할 수 있는 운영 서비스에서SemanticChunker를 선택하는 것이 적합하다. -
(Chroma는 persist_directory·SQLite, FAISS는 save_local·파일)
Chroma.from_documents()는persist_directory파라미터에 경로를 지정하면chroma.sqlite3파일로 자동 영구 저장되고, 불러올 때는 동일 경로를Chroma(persist_directory=...)생성자에 넘기면 된다. 저장된 문서 수 확인은_collection.count().FAISS.from_documents()는 메모리 내 인덱스만 만들며, 명시적으로faiss_db.save_local("./faiss_index")를 호출해야 파일로 저장된다. 불러올 때는FAISS.load_local("./faiss_index", embeddings, allow_dangerous_deserialization=True), 저장된 문서 수는index.ntotal로 확인한다.
# Chroma 저장/로드
vectordb = Chroma.from_documents(splits, embedding_fn, persist_directory="./chroma_db")
vectordb = Chroma(embedding_function=embedding_fn, persist_directory="./chroma_db")
print(vectordb._collection.count())
# FAISS 저장/로드
faiss_db = FAISS.from_documents(splits, embedding_fn)
faiss_db.save_local("./faiss_index")
loaded_db = FAISS.load_local("./faiss_index", embedding_fn, allow_dangerous_deserialization=True)
print(loaded_db.index.ntotal)
-
(chunk_size=200은 정보 부족·청크 폭발 문제를 유발) 청크가 너무 작으면 하나의 청크에 담기는 정보량이 부족해 LLM이 답변을 생성하기에 충분한 컨텍스트를 얻지 못한다. 또한 청크 수가 급증해 벡터 저장 비용과 검색 시간이 늘어난다. 본문의 실무 권장값(
chunk_size=1000, chunk_overlap=200)을 기본값으로 시작하고, RAG 정확도(답변 품질)를 측정하면서 도메인·문서 길이에 맞게 조정한다. -
(수십만 건 실시간 검색이라면 FAISS가 적합) Chroma는 단일 노드·SQLite 기반으로 소~중규모 프로토타이핑에 최적화되어 있고, GPU 가속과 분산 검색을 지원하지 않는다. FAISS는 Meta(Facebook AI)가 개발한 대규모 인덱싱 라이브러리로 GPU 가속을 지원하고 수백만 벡터를 고속으로 검색할 수 있어 수십만 건의 뉴스 실시간 검색에 적합하다. 단, FAISS는 분산 환경 지원이 제한적이므로 노드가 여러 대로 확장되어야 한다면 Pinecone(관리형 클라우드)이나 Milvus(분산 아키텍처)를 추가로 검토해야 한다.
-
(원인: @st.cache_resource 누락, 해결: 무거운 초기화 함수에 데코레이터 추가)
build_chain()함수에@st.cache_resource데코레이터가 없으면 스트림릿은 매 사용자 입력마다 함수 전체를 재실행한다. 이 함수 안에PyPDFLoader,Chroma.from_documents()등 무거운 초기화 코드가 있으면 매번 PDF 재로드와 임베딩 재생성이 발생한다. 해결책은 초기화 함수 상단에@st.cache_resource를 추가하는 것이다.
클릭하거나 Space를 눌러 뒤집기