AI 부트캠프/챕터3(11.08~12.04)

TIL 38 개인 과제

musukie 2024. 11. 14. 21:08

 LLM 과 RAG를 활용하여 AI 챗봇을 구현해보자

 1. 사용환경 준비

환경 변수에서 api 키를 가져오는 과정에서 문제가 발생했다. 처음에 작성한 코드는 아래와 같다.

import openai
import os
from dotenv import load_dotenv

# .env 파일 로드
load_dotenv()   # .env 파일을 로드하여 환경 변수들을 설정한다.

# .env 파일에서 api 키 가져오기
API_KEY = os.getenv('sparta_api_key')

# openai 클라이언트에 API 키 설정
openai.api_key = API_KEY

이 코드를 통해 api 키를 가져왔고, 전체 코드를 실행하니 자꾸 오류가 발생했다.

openai.OpenAIError: The api_key client option must be set either by passing api_key
to the client or by setting the OPENAI_API_KEY environment variable

혼자 이것저것 해보다가 안 돼서 튜터님께 찾아갔다.

import os
from getpass import getpass
os.environ["OPENAI_API_KEY"] = getpass("OpenAI API key 입력: ")

우선 위의 코드로 api key가 잘 작동하는 지 확인해봤다. api key는 제대로 가져오고 있다는 것을 알 수 있었다.

 

그러나 open.api_key는 현재 더이상 사용하지 않아도 된다고 한다. 그 이유는 OpenAI API 클라이언트가 OPENAI_API_KEY라는 환경 변수를 자동으로 읽기 때문이라고 한다. 따라서 최신 버전에서는 os.environ['OPENAI_API_KEY'] = API_KEY 방식으로 환경 변수를 설정하는 방법이 올바르다고 한다.

 

다시 내용을 정리해보자.

openai.api_key = API_KEY는 OpenAI의 Python 클라이언트 라이브러리에서 API 키를 설정하는 방법이다. 이전에는 이 방식이 사용되었고, OpenAI API를 호출할 때 api_key 값을 명시적으로 지정하는 방법이다. 하지만 최신 버전의 OpenAI 라이브러리에서는 환경 변수 방식이 더 많이 사용된다. 즉, API 키를 openai.api_key = API_KEY로 직접 설정하는 대신, 환경 변수를 사용하여 API 키를 관리하는 것이 권장된다.

openai.api_key = API_KEY os.environ['OPENAI_API_KEY'] = API_KEY
직접 API 키를 openai 객체에 설정하는 방식 환경 변수를 사용하여 API 키를 설정하는 방식
코드에서 명시적으로 API 키를 지정하기 때문에 보안적으로 안전하지 않거나 코드가 배포될 때 민감한 정보를 포함할 위험이 있다. 보통 .env 파일을 사용하여 API 키를 관리하고, 코드에서 환경 변수를 통해 API 키를 참조합니다. 이 방식이 보안상 더 안전하며, 여러 환경에서 쉽게 API 키를 관리할 수 있다.

 2. 모델 로드하기

모델은 OpenAI 모델을 로드했다.

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage

# 모델 초기화
model = ChatOpenAI(model="gpt-4o-mini")

순서대로 살펴보자. 먼저 LangChain 라이브러리에서 OpenAI의 챗봇 모델을 사용하기 위한 코드를 불러왔다. 즉, OpenAI의 챗 모델을 사용하여 언어 모델을 초기화하기 위한 코드다.

 

다음으로,  LangChain 라이브러리에서 HumanMessage 클래스를 임포트하는 코드를 불러왔다. HumanMessage 클래스는 LangChain의 메시지 처리 시스템에서 사용자 메시지를 나타내는 데 사용된다. LangChain은 대화형 AI 시스템을 구축할 때 여러 종류의 메시지를 처리하는데, HumanMessage는 사람이 보낸 메시지를 모델에 전달할 때 사용된다.

 

그리고 ChatOpenAI 객체를 초기화하여 사용할 모델을 설정한다. 여기서는 'gpt-4o-mini'로 모델을 설정했다.

 


3. 문서 로드하기

langchain의 PyPDFLoader 를 이용해 문서를 불러왔다. 여기서 PyPDFLoader는 PDF 파일을 로드하여 텍스트를 추출하는 클래스다.

from langchain_community.document_loaders import PyPDFLoader

# PyPDFLoader 인스턴스 생성. PDF 파일 로드 준비.
loader = PyPDFLoader("ch3/[2024 한권으로 ok 주식과 세금].pdf")

# PDF에서 텍스트 추출. 페이지 별 문서 로드
docs = loader.load()

 

아래의 코드로 불러온 pdf 파일에서 추출된 텍스트를 확인해볼 수 있다.

for doc in docs:
	print(doc)

 4. 문서 청크로 나누기

텍스트를 분할하기 위해 코드를 가져왔다.

from langchain.text_splitter import CharacterTextSplitter
from langchain.text_splitter import RecursiveCharacterTextSplitter

두 코드 모두 LangChain 라이브러리의 text_splitter 모듈에서 각각의 클래스를 가져온 것이다.

전자는 langchain.text_splitter에서 CharacterTextSplitter 클래스를 가져왔다. 이 클래스는 텍스트를 여러 덩어리로 분할하는 기능을 제공한다.

후자는 langchain.text_splitter에서 RecursiveCharacterTextSplitter라는 클래스를 가져왔다. 이 클래스는 긴 텍스트를 의미가 끊기지 않도록 일정한 길이의 조각으로 나누는 역할을 한다.

 

문서를 chunk 단위로 나누는 이유는 효율적인 처리를 위해서이다. 문서가 너무 길거나 복잡할 경우, 이를 한 번에 처리하기 어렵거나 메모리와 시간 측면에서 부담이 클 수가 있다. 특히 긴 문서를 다룰 때, chunk 단위로 나누어 처리하면 더 효과적이고 유연하게 활용할 수 있다. 문서를 작은 조각(chunks)으로 나누었을 때의 장점들을 살펴보자.

  • 더 나은 성능: 긴 문서를 한꺼번에 분석하기보다 작은 조각을 처리하는 게 훨씬 빠르고 메모리 효율적이다.
  • 정확도 향상: 모델이 각 조각을 집중적으로 이해할 수 있게 해 줘서 분석이나 요약의 정확도가 높아질 수 있다.
  • 병렬 처리 가능: 작은 조각들은 여러 프로세스나 서버에서 동시에 처리할 수 있어서 더 빠르게 작업을 끝낼 수 있다.
  • 부분적 검색/요약 가능: 필요할 때 문서의 특정 부분만을 검색하거나 요약하는 데 유용하다.

이번 과제에서는 CharacterTextSplitter 방식과 RecursiveCharacterTextSplitter 방식 2가지를 사용했다.

CharacterTextSplitter

먼저 CharacterTextSplitter에 대해 알아보자. 이는 텍스트를 일정한 문자 단위로 나누기 위한 도구다. 주로 자연어 처리를 할 때 긴 문서를 다루는 데 사용된다. LangChain 라이브러리에서 제공하며, 텍스트를 일정한 크기로 조각내서 대규모 언어 모델이나 요약, 질문응답 작업에 적합하게 만들어 준다.

주요 특징을 살펴보자.

  • 최대 길이 설정: 각 텍스트 조각이 가지는 최대 길이를 지정할 수 있다. 이를 통해 모델이 부담 없이 처리할 수 있는 크기로 텍스트를 나눌 수 있다.
  • 중복 포함 옵션: 조각 간에 중복되는 텍스트를 추가할 수도 있는데, 이는 문맥을 유지하고 문장의 연속성을 확보하는 데 도움을 준다.
  • 구분자 설정: 특정 구분자를 기준으로 텍스트를 나눌 수 있다. 예를 들어, 문단을 구분할 때 줄바꿈 문자(\n)나 공백을 기준으로 나눌 수 있다.

예시를 통해 자세히 알아보자.

from langchain.text_splitter import CharacterTextSplitter

text = "이 문서는 아주 길고 길어서 한 번에 처리하기 어렵습니다. 그래서 여러 조각으로 나눕니다."
splitter = CharacterTextSplitter(chunk_size=20, chunk_overlap=5)
chunks = splitter.split_text(text)
print(chunks)

위 코드에서는 chunk_size=20으로 각 조각의 길이가 최대 20자가 되도록 했고, chunk_overlap=5로 설정하여 조각 간에 5자씩 중복되게 했다. 이 설정에 따라 텍스트는 다음과 같이 나뉘게 된다.

[
    "이 문서는 아주 길고 길어서",
    "길어서 한 번에 처리하기",
    "처리하기 어렵습니다. 그래서",
    "그래서 여러 조각으로 나눕니다."
]

 

  • 첫 번째 조각: "이 문서는 아주 길고 길어서" (20자)
  • 두 번째 조각: "길어서 한 번에 처리하기" (중복된 5자 포함, 20자)
  • 세 번째 조각: "처리하기 어렵습니다. 그래서" (중복된 5자 포함, 20자)
  • 네 번째 조각: "그래서 여러 조각으로 나눕니다." (마지막 조각, 20자 이하)

이처럼 CharacterTextSplitter는 설정한 최대 길이 안에서 단순히 문자 수를 기준으로 텍스트를 나누고, 중복을 포함하여 조각을 자연스럽게 연결할 수 있도록 해준다.

 

RecursiveCharacterTextSplitter

다음으로 RecursiveCharacterTextSplitter에 대해 알아보자. 이는 CharacterTextSplitter보다 더 유연하고 똑똑하게 텍스트를 나누기 위해 사용된다. 긴 텍스트를 일정한 길이로 나누는 점에서는 CharacterTextSplitter와 비슷하지만, 분할할 때 여러 기준을 사용하여 최대한 문장이나 문단의 흐름을 해치지 않도록 텍스트를 조각낸다는 점에서 다르다. recursive는 '순환의'라는 뜻을 가진다. 내가 해석하기로는 흐름을 해치지 않는 선에서 문단, 문장, 단어 순으로 구분자를 시도하기 때문에 순환한다고 하는 것 같다.

주요 특징을 살펴보자.

  • 구분자 우선순위: 텍스트를 자를 때 여러 구분자를 순서대로 적용한다. 예를 들어, 문단(\n\n), 문장(.), 단어( ) 순으로 구분자를 시도하면서, 가능한 가장 큰 덩어리를 만들지만 여전히 지정한 최대 길이를 초과하지 않도록 한다.
  • 문맥 유지: 여러 구분자를 사용하기 때문에 조각을 만들 때 문맥이나 문장 구조가 유지될 가능성이 높다. 결과적으로 문단이나 문장이 잘 끊기지 않고, 의미가 잘 전달되도록 분할해준다.
  • 최적화된 길이 유지: 설정한 최대 길이 안에서 가능하면 긴 조각을 만들기 때문에, 텍스트를 처리하는 데 필요한 작업량을 줄일 수 있다.

예시를 통해 자세히 알아보자.

from langchain.text_splitter import RecursiveCharacterTextSplitter

text = "이 문서는 아주 길고 길어서 한 번에 처리하기 어렵습니다. 그래서 여러 조각으로 나눕니다. 또한 문단을 나누거나 문장 단위로 나누어 의미가 전달되도록 할 수 있습니다."
splitter = RecursiveCharacterTextSplitter(chunk_size=50, chunk_overlap=10, separators=["\n\n", ".", " "])
chunks = splitter.split_text(text)
print(chunks)

위 코드에서는 chunk_size=50과 chunk_overlap=10으로 설정했기 때문에, 각 조각의 최대 길이는 50자이며, 각 조각 간에 10자의 중복이 포함될 수 있다. separators에는 \n\n, . , 공백이 포함돼있어, 문단 단위로 나누는 것을 우선으로 시도하고, 안되면 문장이나 단어 단위로 점차 나눠지도록 설정돼있다. 이 설정에 따라 텍스트는 다음과 같이 나뉘게 된다.

[
    "이 문서는 아주 길고 길어서 한 번에 처리하기 어렵습니다.",
    "그래서 여러 조각으로 나눕니다. 또한 문단을 나누거나",
    "문장 단위로 나누어 의미가 전달되도록 할 수 있습니다."
]
  • 첫 번째 조각: "이 문서는 아주 길고 길어서 한 번에 처리하기 어렵습니다." (50자)
  • 두 번째 조각: "그래서 여러 조각으로 나눕니다. 또한 문단을 나누거나"  (중복된 10자 포함, 50자)
  • 세 번째 조각: "문장 단위로 나누어 의미가 전달되도록 할 수 있습니다." (마지막 조각, 50자 이하)

이렇게 각 조각이 설정된 길이에 맞게 최대한 의미를 유지하며 나뉘고, 중복 구간이 포함되어 문맥을 자연스럽게 연결할 수 있게 해준다.

 

내 코드는 각각 아래와 같다.

더보기
더보기

CharacterTextSplitter

text_splitter = CharacterTextSplitter(
    separator="\n\n",   # 두 개의 개행 문자를 구분자로 사용
    chunk_size=100, # 최대 100자씩 나눈다.
    chunk_overlap=10,   # 각 조각에 앞뒤로 5자의 중복을 포함한다.
    length_function=len,    # 길이를 계산할 때 문자 수(len)를 기준으로 한다.
    is_separator_regex=False,   # 구분자를 단순 문자열로 처리
)

splits = text_splitter.split_documents(docs)

print(splits[:10]) # 청킹된 내용 상위 10개 출력

RecursiveCharacterTextSplitter

recursive_text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=100, # 최대 100자씩 나눈다.
    chunk_overlap=10,   # 각 조각에 앞뒤로 5자의 중복을 포함한다.
    length_function=len,    # 길이를 계산할 때 문자 수(len)를 기준으로 한다.
    is_separator_regex=False,   # 구분자를 단순 문자열로 처리
)

splits_RCT = recursive_text_splitter.split_documents(docs)

print(splits_RCT[:10])  # 청킹된 내용 상위 10개 출력

'AI 부트캠프 > 챕터3(11.08~12.04)' 카테고리의 다른 글

WIL 6  (0) 2024.11.15
TIL 39 필수 과제  (0) 2024.11.15
TIL 37  (1) 2024.11.13
TIL 36  (2) 2024.11.12
TIL 35  (3) 2024.11.11