DevLog/LangChain

LangChain #3 – 문서 기반 챗봇 구조 이해와 RAG 실습

archive-log 2025. 4. 15. 15:24

이전글 LangChain #2 - 개발 환경 세팅 + LLM + 문서 기반 답변에서는 텍스트 문서를 프롬프트에 통째로 넣고 LLM에게 질문과 함께 전달해 응답을 생성했다.
짧은 문서라면 충분히 잘 작동하지만, 문서가 길거나 여러 개일 경우에는 토큰 한도, 성능, 관리 면에서 명확한 한계가 존재한다.

 

이번 글에서는 그 구조를 더 유연하게 확장한 RAG(Retrieval-Augmented Generation) 개념을 소개하고, LangChain으로 이를 직접 구현해 보려고 한다.


직접 삽입 방식의 한계

[문서 내용] + [질문] → [LLM 프롬프트] → [응답]

 

직접 삽입 방식의 경우 문서를 직접 프롬프트에 넣기 때문에 구현이 매우 간단하다.

하지만 이 방식은 문서가 길어지거나 여러 개일 경우, LLM의 입력 한도를 쉽게 초과하게 되고, 질문과 관련 없는 내용까지 함께 들어가기 때문에 정확도와 효율성 모두에서 한계가 있다.

 

 

그래서 필요한 구조: RAG(Retrieval-Augmented Generation)

[질문] → [유사한 문서 검색 (Retrieval)] → [문서 일부 추출] → [LLM 응답 생성 (Generation)]

 

RAG는 사용자의 질문에 따라 관련된 문서 조각만 찾아내고, 그 조각만 프롬프트에 삽입하여 LLM이 응답을 생성하도록 하기 때문에

긴 문서도 다룰 수 있고, 정확한 정보만 골라 넣을 수 있어 성능과 유연성이 모두 향상된다.

RAG 구조에서는 문서들을 벡터(숫자 배열)로 변환한 뒤, 질문과 가장 유사한 문서 조각을 검색해야 한다.
이때 필요한 것이 바로 벡터 저장소(Vector Store)다.

 

 

벡터 저장소란?

문서를 분할한 뒤에는 각 조각을 벡터(숫자 배열)로 변환한다.
이 벡터는 LLM이 이해할 수 있도록 변환된 의미 기반 표현이며, 문장 간의 유사도를 수치적으로 계산할 수 있게 해 준다.

벡터화된 문서 조각은 벡터 저장소(Vector Store)에 저장되며, 질문이 들어왔을 때 해당 질문을 같은 방식으로 벡터화한 뒤
가장 가까운 벡터(= 의미상 가장 유사한 문서)를 찾아낸다.

 

 

그렇다면 벡터 저장소, 무엇을 써야 할까?

이번 실습에서는 FAISS를 사용하지만, 실제 상황에서는 다양한 선택지가 존재하며, 프로젝트의 성격에 따라 적절한 저장소를 선택하는 것이 중요하다.

 

주요 벡터 저장소 비교

이름 특징 추천 환경
FAISS 빠르고 가볍다. 로컬에서 사용 용이 실습, 개인 프로젝트
Chroma Python 기반, 설치 간편, 필터링 지원 빠른 프로토타이핑
Qdrant 고성능, REST API, 메타데이터 필터링 중소 규모 서비스 백엔드
Weaviate GraphQL 지원, 다양한 플러그인 복잡한 데이터 구조
Pinecone 완전 관리형 SaaS, 대규모 처리 상용 서비스, 높은 신뢰성
Milvus 분산형 벡터 DB, GPU 최적화 AI/ML 연구, 초대용량 서비스

상황에 따라 아래와 같은 조합으로 사용할 수 있다.

상황 추천 조합
빠른 실습/테스트 FAISS, Chroma
인터넷 없이 실행 Ollama + Chroma
실서비스 Qdrant, Pinecone, Weaviate
AI 연구/대규모 인프라 Milvus

 

Ollama + Chroma 조합

더보기

요즘은 OpenAI 대신, Ollama로 로컬 LLM을 실행하고 Chroma로 벡터 검색을 처리하는 방식도 많이 사용된다.

Ollama - 로컬 LLM 런타임 (예: Mistral, Llama3 등 실행 가능)
Chroma - 경량 벡터 DB. 문서 임베딩 저장 및 검색 담당

 

 

이 조합은 인터넷 없이도 작동하고, 개인 문서를 다루거나 민감한 정보가 있을 때 매우 유용하다.
다만 모델 성능은 OpenAI GPT-4보다는 낮을 수 있으므로, 정밀한 품질이 중요한 서비스에는 상용 API 기반이 여전히 유리하다.

 

벡터 저장소는 단순한 데이터베이스가 아니라 RAG 구조의 핵심 컴포넌트이며, 사용 목적과 실행 환경에 따라 전략적으로 선택해야 한다.


 

RAG 실습

 

1. 벡터 저장소 구축

이번 실습에서는 문서, DB 를 벡터 저장소에 저장하는 두가지 방법을 모두 진행했다.

 

 

1-1. 문서 내용을 벡터 저장소에 저장

 
이전 실습에서 사용한 문서 (data/policy.txt) 의 내용을 벡터 문서로 만들어 저장한다.
 
 
TextLoader, FAISS 사용을 위해  langchain-community(템플릿 외 부가 도구 및 체인 관리) 의존성을 추가,
poetry install 명령어로 설치한다.
langchain-community = "0.3.1"
from langchain.text_splitter import CharacterTextSplitter
from langchain_community.document_loaders import TextLoader
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS

# 문서 로딩
loader = TextLoader("data/policy.txt", encoding="utf-8")
documents = loader.load()

# 문서 분할
# chunk_size = 100자 단위로 분할
# chunk_overlap = 20자는 앞뒤로 겹쳐서 분할(문장이 반으로 잘리는 걸 방지하기 위한 설정)
splitter = CharacterTextSplitter(chunk_size=100, chunk_overlap=20)
docs = splitter.split_documents(documents)

# 임베딩 생성 + 벡터 저장소 구축
embeddings = OpenAIEmbeddings()
vectorstore = FAISS.from_documents(docs, embeddings)
vectorstore.save_local("policy_index")
 
 

📘 문서 분할 기준

더보기

✅  문서가 짧을 때도 분할이 도움이 되는 경우

 

1. 여러 개의 짧은 문서가 있는 경우

  예: FAQ 형태로 정리된 항목들 (질문 + 답변)

  이럴 경우 각 항목이 하나의 "의미 단위"가 되므로 FAQ 하나당 하나의 조각으로 분할해서 처리하는 게 좋음

 

2. 질문이 특정 문장에만 해당될 수 있는 경우

  예: 한 문서에 여러 정책(예: 환불, 배송, 포인트)이 짧게 함께 있을 때

  질문이 "환불"에 대한 것이라면 전체 문서보다 환불 문단만 벡터로 검색되는 게 정확도에 유리함


❌  꼭 분할하지 않아도 되는 경우

 

1. 문서가 정말 짧고, 내용도 하나의 주제에 집중된 경우

  예: 단락 1~2개 정도, 총 500자 이내

  질문이 들어와도 어차피 전부 다 넣어도 무방하고, 분할해도 검색 결과가 크게 달라지지 않음

2. 검색보다는 전체 문맥 유지가 더 중요한 경우

  예: 응답이 문서 전체 흐름을 파악해야 하는 질문이라면 조각으로 쪼개면 오히려 LLM이 연결을 못할 수 있음


🔧  정리

문서 길이와 문서의 구성 방식을 보고 판단

일반적으로는 500자 이상 or 여러 문단 이상이면 분할하고 짧은 정책 문서나 단일 개념 중심이면 분할 없이 사용해도 무방함

 

1-2. DB 데이터를 벡터 저장소에 저장

from sqlalchemy import create_engine, text
from langchain.schema import Document
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
import urllib.parse

# 1. DB 연결
password = urllib.parse.quote_plus("password!@") # 비밀번호에 특수문자가 들어갈 경우 사용
ip = 127.0.0.1
port = 3306
dbname = policy
engine = create_engine(f"mysql+pymysql://root:{password}@{ip}:{port}/{dbname}")

# 2. 게시글 & 카테고리 조회
query = """
SELECT IDX, CONTENT
FROM POLICY
"""

with engine.connect() as conn:
    result = conn.execute(text(query))
    rows = result.mappings().all()
  
print(len(rows))

# 3. 벡터 문서 리스트 생성
docs = []
for row in rows:
    idx = row["IDX"]
    content = row["CONTENT"]

    full_text = f"정책 번호: {idx}\n정책 내용: {content}"

    doc = Document(
        page_content=full_text,
        metadata={
            "idx": idx,
            "content": content
        }
    )
    docs.append(doc)

# 임베딩 생성 + 벡터 저장소 구축
embeddings = OpenAIEmbeddings()
vectorstore = FAISS.from_documents(docs, embeddings)
vectorstore.save_local("policy_db_index")
 

 

벡터 저장소를 구축하면 아래와 같이 save_local 에 명시한 이름으로 폴더가 생성되고 faiss, pkl 파일이 생성되는 것을 확인할 수 있다.

 

 

2. 테스트 진행

from langchain_openai import OpenAIEmbeddings
from langchain_openai import ChatOpenAI
from langchain_community.vectorstores import FAISS
from langchain.chains import RetrievalQA
from pprint import pprint

# 로컬 인덱스 불러오기
embeddings = OpenAIEmbeddings()
vectorstore = FAISS.load_local("policy_index", embeddings, allow_dangerous_deserialization=True)
print(vectorstore.index.ntotal) # 벡터 저장소에 저장된 갯수

# 검색기 생성
# search_kwargs={"k": n} n개 검색 
retriever = vectorstore.as_retriever(search_kwargs={"k": 1})

# 검색 기반 QA 체인 구성
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)

# RetrievalQA는 LangChain에서 문서 검색과 응답 생성을 한 번에 처리할 수 있도록 구성된 체인
# 질문이 들어오면 관련 문서를 검색기로 찾고, 
# 그 문서를 LLM에게 전달해 응답을 생성하는 RAG 구조를 단순하게 구현할 수 있다.
qa = RetrievalQA.from_chain_type(llm=llm, retriever=retriever)

# 질문
query = "환불 신청은 언제까지 가능하고 처리는 얼마나 걸리나요?"

# 실행
response = qa.invoke(query)

# 출력
print(response)

 

 

실행

.py 파일로 코드 작성 후 아래 명령어로 실행할 수 있고 .ipynb 파일로 code, markdown 블록을 구성해 실행할 수 있다.

# 프로젝트 위치에서 실행
poetry run python Test2.py

.ipynb 파일로 실행했을 경우

 

결과

환불 정책 문서의 '환불은 결제일로부터 7일 이내에 요청이 가능합니다.' , '처리까지는 영업일 기준 3일이 소요됩니다.' 내용에 따라 답변이 잘 생성된 것을 확인할 수 있다.

{'query': '환불 신청은 언제까지 가능하고 처리는 얼마나 걸리나요?',
 'result': '환불 신청은 결제일로부터 7일 이내에 가능하며, 처리까지는 영업일 기준 3일이 소요됩니다.'}

이번 실습에는 문서를 벡터로 변환하고, 질문에 따라 관련된 조각만 검색해 LLM이 응답을 생성하는 구조를 구현해 보았다.

이를 위해 LangChain의 RetrievalQA 체인을 사용했으며, 검색(Retrieval)과 생성(Generation)을 별도 구성 없이 단일 체인으로 처리할 수 있었다는 점이 핵심이다.

 

다음 글에서는 도구(tool)의 개념을 살펴보고, 이를 직접 정의하고 사용하는 방법까지 정리해보려고 한다.

LangChain #4 – LangChain 도구(tool)란 무엇인가?