LangChain으로 RAG 구현하기
해당 LangChain은 ver1로 나온지 별로 되지 않았다.
파싱 & 청킹
원하는 txt 파일을 불러온다.
from langchain_community.document_loaders import TextLoader
# 1. TextLoader를 사용하여 파일 불러오기
loader = TextLoader("./shipping_policy.txt")
documents = loader.load()
- pdf는
PyPDFLoader으로 로드한다.
우선 LLM이 문서를 이해하기 위해 청킹/파싱이 필요하다.
왜 이런 작업을 해줘야 하는가?
-
Chunking
LLM은 한 번에 모든 문서를 통째로 읽고 이해할 수 없다.
LLM이 한 번에 처리할 수 있는 텍스트의 양에는 제한이 있는데, 이걸 ‘컨텍스트 창(Context Window)’이라고 부른다.
만약 문서가 이 컨텍스트 창보다 훨씬 길다면, LLM은 문서의 앞부분만 읽거나 뒷부분만 읽게 되어 전체 내용을 파악하기 어렵게 된다.
따라서 긴 문서를 컨텍스트 창 크기에 맞는 작은 ‘청크(Chunk)’들로 나누는 것이다.
이렇게 하면 LLM이 각 청크를 하나씩 집중해서 읽고 이해할 수 있게 된다.
from langchain_text_splitters import RecursiveCharacterTextSplitter # 2. RecursiveCharacterTextSplitter를 사용하여 문서 분할 # 적절한 chunk_size와 chunk_overlap을 설정합니다. # 여기서는 예시로 설정하며, 실제 적용 시에는 데이터 특성에 맞게 조정해야 합니다. text_splitter = RecursiveCharacterTextSplitter( chunk_size=200, # 청크 최대 크기 (문자 단위) chunk_overlap=20, # 청크 간 겹치는 부분 (문자 단위) length_function=len, # 길이를 문자로 계산 is_separator_regex=False, ) chunks = text_splitter.split_documents(documents) print("\n--- 분할된 청크 정보 ---") print(f"생성된 청크 개수: {len(chunks)}") for i, chunk in enumerate(chunks): print(f" 청크 {i+1} (길이: {len(chunk.page_content)}):") print(f" '{chunk.page_content[:100]}...'") # 각 청크의 앞부분만 출력 print(f" 메타데이터: {chunk.metadata}") print("-" * 20)chunk_size: 청크 최대 크기 (문자 단위)chunk_overlap: 청크 간 겹치는 부분 (문자 단위)

-
Parsing LLM은 기본적으로 ‘텍스트’ 형태로 된 정보를 가장 잘 처리한다.
하지만 우리가 보는 PDF 파일에는 표, 그림, 글자 크기 변화가 있고, 웹페이지에는 이미지, 링크, 복잡한 구조가 있다.
파싱은 이렇게 다양한 형태의 문서에서 LLM이 이해할 수 있는 ‘순수한 텍스트’와 필요한 정보(메타데이터 등)를 뽑아내는 과정이다.
마치 문서에서 중요한 내용만 필기하는 것과 같다.
-
Tokenizing 추가적으로 긴 텍스트를 다루는 방법에는 토크나이징이 있다.
텍스트를 LLM이 이해하는 작은 단위(단어, 구두점 등)인 ‘토큰’으로 쪼개는 과정이다.
LLM은 토큰 단위로 텍스트를 읽고 처리하며, 모델마다 처리할 수 있는 최대 토큰 수(컨텍스트 창 크기)가 정해져 있다.
python에서는
tiktoken라이브러리를 사용하여 토크나이징할 수 있다.# 모델에 따라 인코딩 방식이 다를 수 있음. 여기서는 'cl100k_base'를 사용. import tiktoken # 예시 텍스트 text = """ AI 온라인 서점입니다. 고객님의 소중한 문의에 감사드립니다. 배송 정책에 대해 안내해 드리겠습니다. 일반 도서의 경우 오후 3시 이전 주문 시 당일 발송됩니다. 주말 및 공휴일은 배송이 어렵습니다. 제주 및 도서 산간 지역은 추가 배송비가 발생할 수 있습니다. 주문 번호 order-123의 배송 상태를 조회하시려면 마이페이지에서 확인 부탁드립니다. """ encoding = tiktoken.get_encoding("cl100k_base") tokens = encoding.encode(text)
위의 코드는 텍스트 변수로 예시를 들었다.
그럼 실제로 pdf를 불러와서 적용해 보면 다음과 같다.
LLM Chain
LLM Chain(체인)이란, 여러 구성 요소들을 결합하여 LLM의 출력을 생성하는 프로세스를 의미한다.
이는 입력 프롬프트를 모델에 전달하고, 모델의 출력을 받아 원하는 형식으로 처리할 수 있도록 구성된 파이프라인이다.
LLM Chain의 구성 요소
- LLM(Large Language Model) : 모델 정의
- Prompt(프롬프트) : LLM에게 쿼리(질문)과 함께 입력하는 예제와 지시사항
- Output Parser(출력 파서, optional) : 출력 결과의 형태를 지정할 수 있는 도구
이러한 LLM Chain을 제공해주는 라이브러리가 LangChain이다.
LLM Chain 구성하는 법
- llm 정의
- prompt 정의
- chain 정의
- chain 호출
# 1. define your favorate llm, solar
from langchain_upstage import ChatUpstage
llm = ChatUpstage()
# 2. define chat prompt
from langchain_core.prompts import ChatPromptTemplate # '대화' 형태로 prompt template 생성
prompt = ChatPromptTemplate.from_messages(
[
("system", "모든 답변은 존댓말로 답변해줘."),
# few-shot prompting
("human", "프랑스의 수도는 어딘지 알아?"), # human request
("ai", "알고 있습니다. 파리입니다."), # LLM response
("human", "일본의 수도는 어딘지 알아?"),
("ai", "알고 있습니다. 도쿄입니다."),
# User Query
("human", "그렇다면, 한국은?"),
]
)
# 3. define chain
from langchain_core.output_parsers import StrOutputParser #문자열(text, string)만 나오게 하는 출력 파서
# chain = prompt | llm # without output parser
chain = prompt | llm | StrOutputParser() # with output parser
# 4. invoke the chain
c_result = chain.invoke({})
print(c_result)
RAG : Retrieval-Augmented Generation
그렇다면 이제 RAG를 LangChain으로 구현해보자.
다음 세 과정은 RAG 시스템에서 외부 문서를 LLM이 활용할 수 있는 형태로 준비하는 파이프라인의 주요 단계이다.
-
- 파싱
- 가장 먼저, 다양한 형식의 원본 문서(PDF, 웹페이지 등)에서 LLM이 처리하기 좋은 ‘순수한 텍스트’와 메타데이터를 추출하는 과정이다.
-
- 청킹
- 파싱된 긴 텍스트를 LLM의 컨텍스트 창 한계와 검색 효율성을 고려하여 의미 있는 작은 단위인 ‘청크’들로 나누는 과정이다.
-
- 인덱싱 (Indexing)
- 이렇게 분할된 텍스트 청크들을 임베딩 모델을 사용하여 벡터로 변환하고, 이 벡터들을 Vector Store에 저장하는 과정 전체를 ‘인덱싱’이라고 부르기도 한다.
Vector Store는 이 벡터들을 효율적으로 검색할 수 있도록 일종의 ‘색인(Index)’을 만든다.
사용자의 질문이 들어오면, 질문도 벡터로 변환한 후, Vector Store의 색인을 사용하여 질문 벡터와 유사한 청크 벡터들을 빠르게 찾아낸다.
Retriever가 이 인덱싱된 Vector Store를 활용하여 검색을 수행한다.
이 포스팅에서는 vector store로
Chroma를 사용한다.
처리 순서
원본 문서 -(파싱)-> 정제된 텍스트 -(청킹)-> 텍스트 청크 리스트 -(인덱싱: 임베딩 & Vector Store 저장)-> Vector Store (검색 가능한 벡터 데이터)
청크까지의 과정은 앞서 설명한 코드랑 같다.
다음 코드는 청킹된 실제 데이터를 사용하여 Vector Store를 생성하고 Retriever를 구축한다.
Vector store 및 retriever 생성
from langchain_community.vectorstores import Chroma
from langchain_upstage import UpstageEmbeddings
# 1. UpstageEmbeddings 인스턴스 생성
# 환경 변수에 Upstage API 키가 올바르게 설정되어 있는지 확인하세요.
embeddings = UpstageEmbeddings(model="embedding-query")
# 2. 청크로부터 Chroma Vector Store 생성
# 'chunks' 변수는 이전 하위 작업에서 성공적으로 생성되었습니다.
vectorstore = Chroma.from_documents(documents=chunks, embedding=embeddings)
print("\n--- Vector Store 생성 완료 ---")
# 선택 사항: 확인을 위해 벡터 스토어에 있는 문서 수를 출력함
try:
print(f"Vector Store에 저장된 문서 개수: {vectorstore._collection.count()}")
except Exception as e:
print(f"Vector Store 문서 개수 확인 중 오류 발생: {e}")
# 3. 벡터 스토어를 검색기로 변환
retriever = vectorstore.as_retriever()
print("--- Retriever 생성 완료 ---")
print("-" * 20)
Naive RAG 수행
from langchain_core.runnables import RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate
from langchain_upstage import ChatUpstage
from operator import itemgetter # itemgetter 임포트
# 이전 단계의 retriever와 llm 사용
# RAG 체인을 위한 프롬프트 템플릿 정의
# 이 템플릿은 검색된 컨텍스트와 사용자 질문을 위한 플레이스홀더를 포함함
rag_prompt = ChatPromptTemplate.from_messages(
[
("system", "너는 AI 온라인 서점의 고객 서비스 에이전트야.\n모든 답변은 존댓말로 답변해줘.\n다음 컨텍스트를 참고해서 질문에 답변해:\n\n{context}"),
("human", "{question}"),
]
)
# 검색된 문서 목록을 하나의 문자열로 포맷하는 헬퍼 함수
def format_docs(docs):
"""문서 목록을 하나의 문자열로 포맷합니다."""
return "\n\n".join(doc.page_content for doc in docs)
# LCEL을 사용하여 Naive RAG 체인 구성
# 체인은 다음을 수행:
# 1. 사용자 질문을 입력으로 받습니다 ('question' 키를 가진 딕셔너리 형태).
# 2. itemgetter('question')을 사용하여 질문 문자열을 추출합니다.
# 3. 질문 문자열을 retriever에게 전달하여 관련 문서를 가져옵니다.
# 4. format_docs를 사용하여 검색된 문서를 포맷합니다.
# 5. 포맷된 컨텍스트와 원본 질문 (RunnablePassthrough 사용)을 rag_prompt에게 전달합니다.
# 6. 결과 프롬프트를 LLM에게 보내 최종 답변을 생성합니다.
rag_chain = (
RunnablePassthrough.assign(context=itemgetter("question") | retriever | format_docs)
| rag_prompt
| llm
)