[LLM 개발기초] 6. LangChain 을 이용한 RAG 구현


지금까지의 LLM 실습은 전적으로 언어모델의 능력에 의존해서 답변을 얻었습니다. 하지만 언어모델과 다양한 대화를 하다보면 특정 주제나 질문에 대한 대답이 부정확한 경우가 있습니다. 어떻게든 대답을 하기위해 얼버무리는 듯한 느낌을 받게되죠.

언어모델이 인터넷 문서들을 바탕으로 학습되어 있다는 사실은 잘 알려져 있습니다. 그러니 인터넷에 공개되지 않고 기업이나 특정 분야 내부에서만 공유되던 지식들은 언어모델에 반영되지 않아서 정확한 또는 기대한 답변을 얻기 힘들다는 점을 알 수 있습니다.

바로 이전 챕터에서 실행해 본 “뉴스 기사 요약” 예제처럼 프롬프트를 사용해서 이런 단점을 어느정도 보완할 수 있습니다. 질문을 던질 때 관련된 자료들을 같이 넣어서 대답의 품질을 올리는 방법이죠. 하지만 근래에는 이보다 더 진보한 RAG/PEFT 와 같은 방법론이 더 주목받고 있습니다.

  • RAG (Retrieval Augmented Generation)
    • 외부 데이터를 모아서 DB화 해두고, 질문이 입력되면 DB 에서 관련된 내용을 찾아서 언어모델이 처리하기 좋게 다듬어서 전달합니다. 프롬프트를 훨씬 체계적으로 만드는 방법론이라 볼 수도 있습니다.
  • PEFT (Parameter-Efficient Fine-Tuning)
    • 언어모델의 일부 파라미터만 미세 조정하여 특정 작업에 맞게 모델을 적응시키는 방법입니다. 언어모델을 다시 만들지 않고도 높은 성능을 기대할 수 있지만, 언어모델의 파라미터를 수정해야 하므로 언어모델에 대한 전문지식과 기술, 컴퓨팅 리소스와 시간이 필요합니다.

PEFT 에 비해 RAG 방식이 AI 입문자가 (이후로도 상당기간동안) 다루기에 편리하고 빠르게 적용해 볼 수 있습니다.

이어지는 몇 개의 챕터에서는 LangChain 과 RAG 를 이용한 실습들을 제공합니다. PEFT 방식은 추후 별도의 중고급 강좌로 작성하겠습니다.


RAG 동작 프로세스

RAG 를 사용하기에 앞서 RAG 의 동작이 어떻게 이루어지는지에 대한 이해가 필요합니다. 기본적으로 RAG 는 프롬프트를 만들어 언어모델에게 질문을 던집니다. 프롬프트 엔지니어링과 동일한 것 같지만 RAG는 프롬프트를 체계적이고 정교하게 만들 수 있도록 해줍니다. 그 방법이 아래와 같습니다.


각 단계별로 살펴보면

  1. 문서 로딩 (Document Loading)
    • 다양한 형식의 문서에서 텍스트를 가져옵니다. (PDF, DOC, 웹 페이지…)
    • LangChain 의 Document Loaders 가 이 역할을 해줍니다.
  2. 문서 분할 (Splitting)
    • 긴 문서를 더 작은 chunk로 나눕니다.
    • 방대한 문서 전체가 질문과 관계된 것은 아니죠. 관련된 문서의 일부분만 질문할 때 사용하면 됩니다.
    • 그래서 문서를 작은 단위인 chunk 로 쪼개서 chunk 단위로 데이터를 다룹니다.
  3. 임베딩 생성 (Enbedding)
  4. 벡터 저장소 구축 (Vector Database)
    • 임베딩 결과인 벡터를 벡터 데이터베이스에 저장합니다.
    • Chroma, FAISS, Pinecone 등을 사용할 수 있습니다.
  5. 쿼리 처리 (Query)
    • 사용자의 질문(쿼리)를 받아 임베딩 과정을 거쳐 벡터를 만듭니다.
    • 쿼리 벡터로 데이터베이스를 검색합니다. (Retriever)
    • 검색 결과들을 결합하여 컨텍스트(텍스트 문서)로 다시 만듭니다.
    • 사용자 질문과 컨텍스트를 결합하여 프롬프트를 구성합니다.

이 과정을 거치면 내 질문과 관련된 내용들만 추려서 만든 요약문이 결합된 프롬프트를 만들 수 있습니다. 당연히 AI 는 자신이 모르던 지식을 참고해서 우리가 원하는 형태로 답변을 할 수 있습니다. 앞선 예제에서 언어모델이 뉴스 원문을 훌륭하게 요약해 냈던 점을 상기해보세요!


RAG 실습 1: 뉴스 원문을 참조해서 대답하는 AI

이번 실습은 뉴스 원문이 있는 URL 에서 텍스트를 추출하고, RAG 프로세스에 따라 벡터 저장소를 구축합니다. 이후 미리 설정된, 뉴스와 관련된 질문을 하면 적절한 대답을 하도록 만듭니다.
(실습에 사용할 llama3.1 버전이 학습에 참조하지 않은 최근 기사를 사용합니다)

이 과정을 순차적으로 나열하면 다음과 같습니다.

  1. 문서 로딩 (Document Loading) : 다양한 형식의 문서에서 텍스트 추출
  2. 문서 분할 (Splitting) : 긴 문서를 더 작은 chunk로 분할
  3. 임베딩 생성 (Enbedding) : chunk 를 벡터로 변환
  4. 벡터 저장소 구축 (Vector Database) : 임베딩 결과인 벡터를 벡터 데이터베이스에 저장
    –> 사용자 질문 설정 –>
  5. 쿼리 처리 (Query-Retriever) : 벡터 DB 에서 참고할 문서 검색
  6. 검색된 문서를 첨부해서 PROMPT 생성
  7. LLM에 질문

코드에 사용할 라이브러리를 먼저 설치합니다.

  • bs4 는 URL 에 접속해서 데이터를 가져오기 위해 사용하는 라이브러리입니다.
  • GPU 가속을 사용할 수 있는 환경이라면 faiss-cpu 대신 faiss-gpu 를 사용하세요.
pip install bs4 langchain_text_splitters faiss-cpu

아래 코드가 실습할 코드입니다. [06_01_rag_news_query.py]

import bs4
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import WebBaseLoader
from langchain_community.vectorstores import FAISS
from langchain_community.embeddings import OllamaEmbeddings
from langchain_community.llms import Ollama
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate

# 1. 문서 로딩 (Document Loading)
loader = WebBaseLoader(
    web_paths=("https://www.bbc.com/korean/articles/cl4yml4l6j1o",),
    bs_kwargs=dict(
        parse_only=bs4.SoupStrainer(
            "div",
            attrs={"class": ["bbc-1cvxiy9", "bbc-fa0wmp"]},
        )
    ),
)
docs = loader.load()
print(f"문서의 수: {len(docs)}")

# 2. 문서 분할 (Splitting)
text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
splits = text_splitter.split_documents(docs)
print(f"split size: {len(splits)}")

# 3. 임베딩 생성 (Enbedding)
embeddings = OllamaEmbeddings(model="llama3.1")

# 4. 벡터 저장소 구축 (Vector Database)
vector_store = FAISS.from_documents(documents=splits, embedding=embeddings)
# 4-1. 쿼리 저장소 검색을 위한 retriever 생성
retriever = vector_store.as_retriever()

# PROMPT Template 생성
prompt = PromptTemplate.from_template(
"""당신은 질문-답변(Question-Answering)을 수행하는 AI 어시스턴트입니다. 당신의 임무는 주어진 문맥(context) 에서 주어진 질문(question) 에 답하는 것입니다.
검색된 다음 문맥(context) 을 사용하여 질문(question) 에 답하세요. 만약, 주어진 문맥(context) 에서 답을 찾을 수 없다면, 답을 모른다면 `주어진 정보에서 질문에 대한 정보를 찾을 수 없습니다` 라고 답하세요.
질문과 관련성이 높은 내용만 답변하고 추측된 내용을 생성하지 마세요. 기술적인 용어나 이름은 번역하지 않고 그대로 사용해 주세요.

#Question: 
{question} 

#Context: 
{context} 

#Answer:"""
)

# Ollama 초기화
llm = Ollama(
    model="llama3.1",
    temperature=0
)

# 체인을 생성합니다.
chain = prompt | llm | StrOutputParser()

# 테스트 할 질문
question = "극한 호우의 원인은 무엇인가?"

# 5. 쿼리 처리 (Query-Retriever) : 벡터 DB 에서 참고할 문서 검색
retrieved_docs = retriever.invoke(question)
print(f"retrieved size: {len(retrieved_docs)}")
combined_docs = "\n\n".join(doc.page_content for doc in retrieved_docs)

# 6. 검색된 문서를 첨부해서 PROMPT 생성
formatted_prompt = {"context": combined_docs, "question": question}

# 7. LLM에 질문
for chunk in chain.stream(formatted_prompt):
    print(chunk, end="", flush=True)

코드가 다소 길지만 1~7 까지 RAG 프로세스 순서를 기억하며 보면 이해에 도움이 될 것입니다.

  1. 문서 로딩 (Document Loading)
    • 웹 페이지에 게시된 기사의 URL 을 입력하고 기사 내용을 긁어옵니다.
    • langchain 에서 제공하는 WebBaseLoader 와 beautifulsoup 라이브러리를 사용합니다.
      • “bbc-1cvxiy9”, “bbc-fa0wmp” 클래스로 정의된 div 태그에서 내용을 가져옵니다.
  2. 문서 분할 (Splitting)
    • 가져온 문서(텍스트)를 chunk size 로 나눕니다.
    • 이때 문단이 중간에 잘릴 수 있으므로 chunk_overlap 만큼 앞 뒤로 여유를 줘서 자릅니다.
    • 문서를 자르면 몇 개로 분할되었는지 출력합니다.
  3. 임베딩 생성 (Enbedding)
    • chunk 로 잘라진 문서들을 임베딩합니다.
      (코드상 임베딩 툴만 지정하고 실제 임베딩은 4에서 실행)
    • 임베딩을 위해 ollama 와 llama3.1 모델을 사용했습니다.
    • 문서가 크면 클수록 시간이 꽤 소요되므로 GPU 가속을 사용하면 좋습니다.
  4. 벡터 저장소 구축 (Vector Database)
    • FAISS 벡터 DB 를 사용합니다.
    • 4-1. 추후 사용자의 질문을 가지고 DB를 검색해야 하므로 이 역할을 해주는 retriever 를 생성합니다.
      –> 이후 프롬프트 템플릿 생성, ollama 초기화, 체인생성의 단계를 진행합니다.
      –> 이 부분은 앞선 예제들과 동일한 프로세스이므로 상세 설명은 생략합니다.
  5. 쿼리 처리 (Query-Retriever)
    • 설정된 질문을 임베딩해서 벡터로 변환합니다.
    • 변환된 질문 벡터를 이용해서 FAISS 벡터 DB를 검색합니다. (retriever 사용)
    • 그러면 관련있는 chunk 들이 검색됩니다.
    • 몇 개의 chunk 가 검색되었는지 출력합니다.
  6. 검색된 문서를 첨부해서 PROMPT 생성
    • 검색된 문서를 합쳐서 컨텍스트(context) 문자열을 만들고 사용자 질문(question)을 준비합니다.
  7. 첨부해서 LLM 에 넘길 프롬프트를 생성합니다.
    • context, question 파라미터를 LLM 에 넘길 때 앞서 설정한 프롬프트 템플릿에 내용이 들어갑니다.

코드를 실행해보면 아래처럼 결과가 나올것입니다!

$ python -u "c:\___Workspace\Llama\test\llama_test\LLM_test\06_01_rag_news_query.py"

문서의 수: 1
split size: 9
retrieved size: 4
저기압과 장마전선이 만나 많은 비를 뿌렸습니다.

$ 

제법 쓸만한 대답을 내놓습니다. 대답의 품질을 높이기 위해 다양한 질문을 준비하고 아래 코드에서 chunk_size, chunk_overlap 값을 변경하면서 실험해보세요.

text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)

RAG 프로세스의 성능을 높이기 위해서는 아래 요소들을 고려해야 합니다.

  • 문서 분할 (Splitting)
    • chunk_size : 작을수록 세분화된 검색이 가능. 대신 컨텍스트 손실 가능성이 높아짐
    • chunk_overlap : chunk 간 연결성이 좋아짐. 대신 중복이 늘어남
  • 임베딩
    • 예제의 ollama 대신 OpenAIEmbeddings, HuggingFaceEmbeddings 등도 테스트
  • 벡터 저장소 검색
  • 프롬프트 템플릿
    • 프롬프트의 구조와 내용을 수정하여 LLM의 응답 방식을 조정
  • LLM 설정
    • llm = Ollama() 코드를 통해 LLM 생성할 때 다른 모델을 사용
    • temperature: 높이면 더 창의적인 응답을, 낮추면 더 결정적인 응답
  • 검색된 문서 처리
    • 문서 결합 방식을 수정하거나, 관련성 점수를 기반으로 필터링을 추가


RAG 실습 2: PDF 로딩과 연속 대화 기능 추가

실습 1의 예제를 실행해보면 불편한 점들이 있습니다.

  1. 매번 URL에서 데이터 가져오는 대신 파일에서 로딩할 수 없을까?
  2. 문서 임베딩에 시간이 오래걸리는데, DB에 저장된 내용을 다시 불러올 순 없을까?
  3. DB에 저장된 내용을 바탕으로 질의 응답을 계속할 수 없을까?

이 문제들을 아래와 같이 수정해보도록 하겠습니다.

  • 1번은 PDF 파일을 langchain_community.document_loaders 에서 제공하는 PyPDFLoader를 사용하면 됩니다.
  • 2번은 DB에 저장된 내용을 파일로 저장하고 다시 실행할 때 불러오는 방식으로 해결할 수 있습니다.
  • 3번은 앞선 챕터에서 실습했던 코드들을 사용해서 구현할 수 있습니다.

PDF 파일에서 데이터를 가져오기 위해서는 pypdf 라이브러리가 필요합니다.

pip install pypdf

아래 코드가 실습할 코드입니다.

이번 예제는 doc 폴더에 있는 news_weather.pdf 파일도 함께 준비해야 합니다. PDF 파일에는 앞선 예제에서 사용했던 기사 원문이 저장되어 있습니다. [06_02_rag_enhanced_query.py]

import os
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import FAISS
from langchain_community.embeddings import OllamaEmbeddings
from langchain_community.llms import Ollama
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_community.document_loaders import PyPDFLoader

# 벡터 DB 파일 경로
VECTOR_DB_PATH = "faiss_index"

# 1. 벡터 DB 파일이 없으면 생성 후 vector_store 리턴
def create_vector_db():
    # 1-1. 문서 로딩 (Document Loading)
    loader = PyPDFLoader("C:\\___Workspace\\......\\doc\\news_weather.pdf")
    docs = loader.load()
    print(f"문서의 수: {len(docs)}")

    # 1-2. 문서 분할 (Splitting)
    text_splitter = RecursiveCharacterTextSplitter(chunk_size=700, chunk_overlap=70)
    splits = text_splitter.split_documents(docs)
    print(f"split size: {len(splits)}")

    # 1-3. 임베딩 생성 (Embedding)
    embeddings = OllamaEmbeddings(model="llama3.1")

    # 1-4. 벡터 저장소 구축 (Vector Database)
    vector_store = FAISS.from_documents(
        documents=splits, 
        embedding=embeddings, 
    )
    
    # 1-5. 벡터 DB를 로컬에 저장
    vector_store.save_local(VECTOR_DB_PATH)
    
    return vector_store

# 2. 메인 로직
if os.path.exists(VECTOR_DB_PATH):
    print("기존 벡터 DB를 로드합니다.")
    embeddings = OllamaEmbeddings(model="llama3.1")
    vector_store = FAISS.load_local(
        VECTOR_DB_PATH, 
        embeddings, 
        allow_dangerous_deserialization=True # 믿을 수 있는 소스임을 확인
    )
else:
    print("새로운 벡터 DB를 생성합니다.")
    vector_store = create_vector_db()


# 3. 쿼리 저장소 검색을 위한 retriever 생성
retriever = vector_store.as_retriever()

# 4. PROMPT Template 생성
prompt = PromptTemplate.from_template(
"""당신은 질문-답변(Question-Answering)을 수행하는 AI 어시스턴트입니다. 당신의 임무는 주어진 문맥(context) 에서 주어진 질문(question) 에 답하는 것입니다.
검색된 다음 문맥(context) 을 사용하여 질문(question) 에 답하세요. 만약, 주어진 문맥(context) 에서 답을 찾을 수 없다면, 답을 모른다면 `주어진 정보에서 질문에 대한 정보를 찾을 수 없습니다` 라고 답하세요.
질문과 관련성이 높은 내용만 답변하고 추측된 내용을 생성하지 마세요. 기술적인 용어나 이름은 번역하지 않고 그대로 사용해 주세요.

#Question: 
{question} 

#Context: 
{context} 

#Answer:"""
)

# 5. Ollama 초기화
llm = Ollama(
    model="llama3.1",
    temperature=0
)

# 6. 체인을 생성합니다.
chain = prompt | llm | StrOutputParser()

# 7. chain 실행 및 결과 출력을 반복
while True:
    # 7-1. 사용자의 입력을 기다림
    question = input("\n\n당신: ")
    if question == "끝" or question == "exit":
        break

    # 7-2. 쿼리 처리 (Query-Retriever) : 벡터 DB 에서 참고할 문서 검색
    retrieved_docs = retriever.invoke(question)
    print(f"retrieved size: {len(retrieved_docs)}")
    combined_docs = "\n\n".join(doc.page_content for doc in retrieved_docs)

    # 7-3. 검색된 문서를 첨부해서 PROMPT 생성
    formatted_prompt = {"context": combined_docs, "question": question}

    # 7-4. 체인을 실행하고 결과를 stream 형태로 출력
    result = ""
    for chunk in chain.stream(formatted_prompt):
        print(chunk, end="", flush=True)
        result += chunk

코드 최상단 즈음에 faiss DB 를 faiss_index 폴더에 저장하도록 지정했습니다. 코드 실행 후 해당 폴더에 index.faiss, index.pkl 파일이 생성되었는지 확인해보면 됩니다.

# 벡터 DB 파일 경로
VECTOR_DB_PATH = "faiss_index"

코드가 다소 길지만 1~7 까지 RAG 프로세스 순서를 떠올리면 이해에 도움이 될 것입니다.

  1. 벡터 DB 파일이 없으면 생성 후 vector_store 리턴
    • 1-1. 문서 로딩 (Document Loading)
      • 이 부분이 PDF 에서 로딩하도록 바뀌었습니다. 본인의 환경에 맞게 수정해야 합니다.
      • 현재 소스코드가 위치한 곳에 있는 doc 폴더 -> 내부에 있는 news_weather.pdf 파일을 참고합니다.
    • 1-2. 문서 분할 (Splitting)
      • PDF 파일로 바뀌면서 URL에서 데이터를 가져올 때와 미세한 차이들이 발생했습니다.
      • 따라서 테스트를 통해 chunk_size, chunk_overlap 값을 조정해야 합니다.
    • 1-3. 임베딩 생성 (Enbedding)
    • 1-4. 벡터 저장소 구축 (Vector Database)
    • 1-5. 벡터 DB를 로컬 스토리지에 저장
      • faiss_index 에 파일이 생성되었는지 확인하세요.
  2. 메인 로직
    • 여기서부터 코드가 시작됩니다.
    • faiss DB 파일이 있는지 검사해서
      • 없으면 PDF 로딩 후 임베딩 -> DB 파일을 생성합니다.
        • 이때 allow_dangerous_deserialization=True 파라미터를 설정해야 합니다.
        • 그렇지 않으면 “믿을 수 없는 소스”라고 경고하면서 동작하지 않습니다.
      • 있으면 파일 로딩 후 바로 사용준비가 끝납니다.
  3. 쿼리 저장소 검색을 위한 retriever 생성
  4. PROMPT Template 생성
  5. Ollama 초기화
  6. 체인을 생성합니다.
  7. chain 실행 및 결과 출력을 반복
    • 7-1. 사용자의 입력을 기다림
    • 7-2. 쿼리 처리 (Query-Retriever) : 질문이 바뀔때마다 벡터 DB 에서 참고할 문서를 다시 검색해야 합니다.
    • 7-3. 검색된 문서를 첨부해서 PROMPT 생성
    • 체인을 실행하고 결과를 stream 형태로 출력

코드를 실행해보면 아래처럼 결과가 나올것입니다.

첫 실행할 때 “새로운 벡터 DB를 생성합니다.” 메시지가 출력되면서 질문 입력을 받기까지 시간이 꽤 걸립니다. 질문에 대한 응답을 확인하고 exit 를 입력하면 실행이 종료됩니다.

다시 파이썬 파일을 실행하면 기존 벡터 DB를 로드하기 때문에 긴 기다림 없이 바로 질문을 할 수 있습니다.

$ python -u "c:\___Workspace\Llama\test\llama_test\LLM_test\06_02_rag_enhanced_query.py"
새로운 벡터 DB를 생성합니다.
문서의 수: 3
split size: 8


당신: 극한 호우의 원인은 무엇인가?
retrieved size: 4
극한 호우의 원인은 뭘까. 극한 호우란 1시간 누적 강수량이 50mm 이상, 3시간 누적 강수량 90mm 이상인 기준을 동시에 충족하거나 1시간 누적 강수량이 72mm 이상인 ......

당신: exit

$ python -u "c:\___Workspace\Llama\test\llama_test\LLM_test\06_02_rag_enhanced_query.py"
기존 벡터 DB를 로드합니다.


당신: 극한 호우의 원인은 무엇인가?
retrieved size: 4
극한 호우의 원인은 뭘까. 극한 호우란 1시간 누적 강수량이 50mm 이상, 3시간 누적 강수량 90mm 이상인 기준을 동시에 충족하거나 1시간 누적 강수량이 72mm 이상인 ......

실행 결과 확인이 끝나면 faiss_index 폴더를 확인하세요. 이 폴더를 삭제하면 다음번 실핼할 때 벡터 DB 파일을 다시 생성합니다.



참고자료


You may also like...

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.