와, RAG가 이렇게 어려운 것이었네요.🤣 소장하고 있는 PDF를 이용한 앱을 계획하였는데 원하는 내용을 정확히 추출하기까지 정말 쉽지 않았어요. 다음은 완성한 험한 말 번역기 “Raw & Real English” 앱의 사용 예시 영상입니다.
목차
험한 말 번역기 “Raw & Real English” 앱 기획
프로그램의 주요 기능
1) 데이터 추출2) 리액트 에이전트 생성
3) 외부 CSS Style 사용
4) gTTS 사용
프로그램 작성
1) 데이터 전처리 및 PDF에서 영어 문장과 한글 문장 추출 함수 정의2) 검색기 생성 및 쿼리와 검색기를 이용하여 PDF에서 문장을 검색하는 함수 정의
3) 모델 설정, 쿼리를 정중한 영어 문장으로 변환하는 함수 정의, 툴 리스트 설정
4) 에이전트 프롬프트 템플릿 설정, 에이전트 생성, 텍스트를 음성으로 변환하는 함수 정의
5) Streamlit 앱 설정, 영어 표현 유형과 내용을 표시하는 함수 정의
6) 'Translate' 버튼을 눌렀을 때 에이전트 실행, 화면에 출력
사용 화면
정리
1. 험한 말 번역기 “Raw & Real English” 앱 기획
RAG(Retrieval-Augmented Generation)는 LLM의 한계를 극복하기 위해 사용한다는데, 무엇을 하면 RAG를 이해할 수 있을지 고민하다가 “험한 말 번역기”라는 주제를 선택하였습니다. (재미삼아 정했는데 과정이 험난했어요.🥶)
험한 말 번역기 “Raw & Real English”는 한글 비속어를 영어로 번역하는 앱입니다. LLM은 비속어를 직역해주지 않으므로 Raw Expression에는 RAG를 활용하고, 공적이고 예의바른 Formal Expression에는 GPT4-o 모델을 사용한다는 아이디어입니다.
앱 UI는 Streamlit으로 구현하고, 번역한 영어 표현들은 gTTS(Google Text-to-Speach)를 이용해 오디오로 들어보도록 하였습니다.
RAG로 이용한 문서는 “싸가지 없는 영어책 - 욕으로 배우는 영어 회화”라는 책의 PDF 파일입니다. 저자의 채널에서 회원 전용으로 공유한 것으로 개인 사용만 가능합니다.
2. 프로그램 주요 기능
1) 데이터 추출
PyPDFLoader와 FAISS를 이용하는 방법으로 원하는 내용이 정확히 추출되지 않아 PyMuPDF(fitz)와 TF-IDF를 이용하는 것으로 변경했습니다.
PyPDFLoader + FAISS
데이터 로딩: PyPDFLoader를 사용하여 PDF 전체 내용을 텍스트로 추출, RecursiveCharacterTextSplitter를 사용하여 긴 텍스트를 작은 청크로 분할
임베딩 생성: OpenAIEmbeddings를 사용하여 각 텍스트 청크를 고차원 벡터로 변환
인덱스 생성: FAISS를 사용하여 벡터들의 인덱스 생성, FAISS는 고차원 벡터의 빠른 유사도 검색을 위한 라이브러리
검색 과정: 쿼리 텍스트도 같은 방식으로 벡터로 변환, FAISS 인덱스를 사용하여 쿼리 벡터와 가장 유사한 벡터들을 검색, 유사한 벡터에 해당하는 원본 텍스트 청크들을 반환
PyMuPDF + TF-IDF
데이터 로딩: PyMuPDF(fitz)를 사용하여 PDF에서 영어 표현과 한국어 번역을 정확하게 추출, {"english": ..., "korean": ..., "page": ...} 형식의 구조화된 데이터로 저장
텍스트 처리: 텍스트에 대해 전처리를 수행(특수문자 제거, 소문자화 등)
TF-IDF 벡터화: sklearn의 TfidfVectorizer를 사용하여 한국어 텍스트를 TF-IDF 벡터로 변환, TF-IDF는 단어의 중요도를 문서 내 빈도와 전체 문서에서 희소성으로 계산
검색 과정: 쿼리 텍스트도 같은 TF-IDF 벡터라이저를 사용하여 벡터로 변환, 코사인 유사도를 사용하여 쿼리 벡터와 모든 문서 벡터 간의 유사도를 계산, 가장 유사도가 높은 문서들을 선택하여 반환
PyMuPDF + TF-IDF 데이터셋이 작을 경우 더 간단하고 직관적이며, 특정 PDF 구조에 맞춰 설계되어 정확할 수 있으나 유연하지 못합니다. PyPDFLoader + FAISS는 다양한 형태의 문서에 일반적으로 적용할 수 있으나 정확도가 떨어질 수 있습니다.
2) 리액트 에이전트 생성
2개의 툴을 만들고, 사용자의 입력에 따라 적절한 툴을 선택하도록 하였습니다.
툴 생성
PDF Search: search_pdf 함수를 사용하여 PDF에서 한국어 문장에 대응하는 영어 표현 검색
Formal Expression Generator: generate_formal_expression 함수를 사용하여 주어진 문장을 공적이고 예의바른 영어 표현으로 변환
프롬프트 템플릿 정의
에이전트의 행동 방식에 대한 상세한 지시사항으로 프롬프트 템플릿 정의
에이전트가 도구를 사용하는 방법, 결과를 분석하는 방법, 최종 답변을 제시하는 형식 등을 지정
리액트 에이전트 생성
create_react_agent 함수를 사용하여 ReAct(Reasoning and Acting) 방식의 에이전트를 생성
이 함수에 언어 모델, 툴 목록, 프롬프트 템플릿을 제공
생성된 에이전트는 사용자의 입력에 따라 적절한 툴을 선택하여 한글 문장을 번역해서 Raw Expression과 Formal Expression을 제공할 수 있습니다. 에이전트는 ReAct 방식을 따라 추론(Reasoning)과 행동(Acting)을 반복하며 최적의 결과를 도출합니다.
3) 외부 CSS Style 사용
Streamlit의 UI 디자인을 위해 파일 외부에 CSS 파일을 만들고 링크하여 사용했습니다.
4) gTTS(Google Text-to-Speach) 사용
번역된 영어 표현을 들어볼 수 있도록 gTTS를 사용해 mp3 파일을 만들고, UI에 오디오 플레이어를 넣었습니다.
3. 프로그램 작성
1) 데이터 전처리 및 PDF에서 영어 문장과 한글 문장 추출 함수 정의
import streamlit as st
import fitz # PyMuPDF
import re
import tempfile
from langchain_openai import ChatOpenAI
from langchain.agents import Tool, AgentExecutor, create_react_agent
from langchain.prompts import PromptTemplate
from typing import List
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from gtts import gTTS
# OpenAI API 키 설정
# 데이터 전처리 함수
def preprocess_text(text: str) -> str:
text = re.sub(r'[^\w\s]', '', text) # 특수 문자를 제거하고 텍스트 정리
return ' '.join(text.lower().split()) # 텍스트를 소문자로 변환하고 공백을 정리
# PDF에서 페이지 상단의 영어 문장과 한글 문장 추출 함수
def extract_text_from_pdf(pdf_path: str, start_page: int, end_page: int) -> List[Dict[str, str]]:
document = fitz.open(pdf_path) # PDF 파일 열기
english_regex = re.compile(r'[a-zA-Z\s\-\'\!\?]+') # 영어 문장을 찾기 위한 정규 표현식
korean_regex = re.compile(r'[가-힣\s\(\)\.\!\?]+') # 한글 문장을 찾기 위한 정규 표현식
extracted_data = [] # 추출된 데이터를 저장할 리스트
for page_number in range(start_page - 1, end_page): # 시작 페이지부터 끝 페이지까지 반복
page = document.load_page(page_number) # 현재 페이지 로드
text = page.get_text("text") # 페이지의 텍스트 추출
lines = text.split('\n') # 텍스트를 줄 단위로 나누기
english_line = '' # 영어 문장을 저장할 변수
korean_line = '' # 한글 문장을 저장할 변수
collecting_english = False # 영어 문장을 수집 중인지 여부를 나타내는 플래그
for line in lines: # 각 줄을 반복
if re.match(r'^Chapter\s\d+\s\d+\s\w+$', line): # 챕터 헤더 라인을 건너뛰기
continue
if english_regex.match(line): # 현재 줄이 영어 문장인지 확인
if not collecting_english: # 처음 영어 문장을 찾은 경우
english_line = line.strip() # 영어 문장을 저장
collecting_english = True # 영어 문장을 수집 중으로 설정
else: # 이미 영어 문장을 수집 중인 경우
english_line += ' ' + line.strip() # 이전 영어 문장에 이어서 추가
elif korean_regex.search(line): # 현재 줄에 한글 문장이 있는지 확인
korean_line = line.strip() # 한글 문장을 저장
collecting_english = False # 영어 문장 수집 중지
english_line = re.sub(r'[가-힣.,]', '', english_line).strip() # 영어 문장에서 한글 및 특수 문자 제거
if english_line and korean_line: # 영어와 한글 문장이 모두 있는 경우
extracted_data.append({ # 데이터를 딕셔너리로 저장
"english": english_line, # 영어 문장
"korean": korean_line, # 한글 문장
"page": page_number + 1 # 페이지 번호
})
return extracted_data # 추출된 데이터 리스트 반환
2) 검색기 생성 및 쿼리와 검색기를 이용하여 PDF에서 문장을 검색하는 함수 정의
# 주어진 PDF 파일 경로를 받아서 검색기를 생성하는 함수
def create_retriever(pdf_path: str):
extracted_data = extract_text_from_pdf(pdf_path, start_page=10, end_page=184)
# PDF(10~184페이지)에서 텍스트 데이터를 추출
vectorizer = TfidfVectorizer() # TF-IDF 벡터라이저 생성
korean_texts = [preprocess_text(item['korean']) for item in extracted_data]
# 추출된 데이터에서 한글 텍스트를 전처리하여 리스트 생성
tfidf_matrix = vectorizer.fit_transform(korean_texts)
# 한글 텍스트 리스트를 TF-IDF 매트릭스로 변환
def search_similar(query, top_k=1): # 주어진 쿼리에 가장 유사한 문장을 찾는 내부 함수 정의
query_vec = vectorizer.transform([preprocess_text(query)])
# 쿼리 텍스트를 전처리하여 벡터로 변환
similarities = cosine_similarity(query_vec, tfidf_matrix)[0]
# 쿼리 벡터와 TF-IDF 매트릭스 간의 코사인 유사도 계산
top_indices = similarities.argsort()[-top_k:][::-1]
# 유사도가 높은 순서대로 정렬하여 상위 인덱스 추출
return [extracted_data[i] for i in top_indices if similarities[i] > 0.3]
# 유사도가 0.3 이상인 상위 결과 반환
return search_similar # 검색기 함수를 반환
# 주어진 쿼리와 검색기를 이용하여 PDF에서 문장을 검색하는 함수
def search_pdf(query: str, retriever) -> str:
results = retriever(query) # 검색기 함수를 이용하여 쿼리에 대한 결과를 얻음
if results: # 결과가 있는 경우
doc = results[0] # 첫 번째 결과를 선택
return f"Raw Expression: {doc['english']}\nKorean: {doc['korean']}\nPage: {doc['page']}"
# 결과를 포맷팅하여 반환
return "No matching expression found." # 결과가 없는 경우 메시지 반환
pdf_path = "assets/english_expressions.pdf" # PDF 파일 경로 설정
retriever = create_retriever(pdf_path) # 검색기 생성
3) 모델 설정, 쿼리를 정중한 영어 문장으로 변환하는 함수 정의, 툴 리스트 설정
# GPT-4o 모델을 사용한 언어 모델 설정
llm = ChatOpenAI(model="gpt-4o", temperature=0)
# 쿼리를 정중한 영어 문장으로 변환하는 함수
def generate_formal_expression(query: str) -> str:
prompt = PromptTemplate( # 프롬프트 템플릿 생성
input_variables=["query"], # 입력 변수로 "query" 설정
template="Translate the following Korean sentence or English expression into a formal and polite English sentence, maintaining the original meaning: '{query}'" # 프롬프트 템플릿 내용
)
chain = prompt | llm # 프롬프트와 언어 모델을 체인으로 연결
result = chain.invoke({"query": query}) # 체인을 호출하여 결과 생성
return result.content # 결과의 내용을 반환
# 툴 리스트 생성
tools = [
Tool( # PDF 검색 툴 정의
name="PDF Search", # 툴 이름
func=lambda q: search_pdf(q, retriever), # 툴 함수
description="Search for English expressions corresponding to Korean sentences in the PDF" # 툴 설명
),
Tool( # 정중한 표현 생성기 툴 정의
name="Formal Expression Generator", # 툴 이름
func=generate_formal_expression, # 툴 함수
description="Generate formal and polite English expressions" # 툴 설명
)
]
4) 에이전트 프롬프트 템플릿 설정, 에이전트 생성, 텍스트를 음성으로 변환하는 함수 정의
# 에이전트 프롬프트 템플릿 설정
prompt = PromptTemplate.from_template(
"""You are an AI assistant that helps translate Korean sentences to English, providing expressions based on user preferences.
You have access to the following tools:
{tools}
Use the following format:
Human: <한글 문장>
Thought: 어떤 도구를 사용해야 할지 고민합니다. 사용자의 요청에 따라 도구를 선택합니다.
Action: <도구 이름>
Action Input: <도구 입력>
Observation: <도구 출력>
Thought: 결과를 분석하고 다음 단계를 결정합니다.
... (필요한 만큼 반복)
Final Answer: 다음 형식으로 결과를 제시합니다:
* Raw Expression: <PDF에서 찾은 원래의 영어 표현>
예문: <영어로 된 사용 예시>
<원래 표현의 의미와 사용 상황에 대한 한글 설명>
* Formal Expression: <공식적인 상황에서 사용할 수 있는 영어 표현>
예문: <영어로 된 사용 예시>
<공식적인 표현의 의미와 사용 상황에 대한 한글 설명>
Raw Expression을 제시할 때는 PDF에서 찾은 표현을 그대로 사용하세요. 비속어나 강한 표현이 포함되어 있더라도 수정하지 마세요.
설명은 반드시 한글로 제공하세요.
사용자의 요청에 따라 Raw Expression만, Formal Expression만, 또는 둘 다 제공해야 합니다.
The available tool names are: {tool_names}
Begin!
Human: {input}
{agent_scratchpad}"""
) # 사용자에게 제공할 프롬프트 템플릿 정의
# 에이전트 생성
agent = create_react_agent(llm, tools, prompt) # 반응형 에이전트 생성
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True, handle_parsing_errors=True) # 에이전트 실행기 생성, 디버깅을 위해 verbose 모드 활성화, 파싱 오류가 발생했을 때 다시 시도
# 텍스트를 음성으로 변환하는 함수
def text_to_speech(text: str) -> str:
tts = gTTS(text=text, lang='en') # gTTS 모듈을 사용하여 텍스트를 음성으로 변환
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as fp: # 임시 파일 생성
tts.save(fp.name) # 변환된 음성을 임시 파일에 저장
return fp.name # 임시 파일 반환
5) Streamlit 앱 설정, 영어 표현 유형과 내용을 표시하는 함수 정의
# Streamlit 앱 설정
st.set_page_config(layout="wide", page_title="Raw & Real English") # 페이지 레이아웃과 제목 설정
st.markdown(f'<style>{open("mystyle.css").read()}</style>', unsafe_allow_html=True) # 외부 CSS 파일을 읽어와 스타일 적용
# 사이드바에 앱 설명과 사용 방법 설정
st.sidebar.title("😁 Raw & Real English") # 사이드바의 제목
st.sidebar.markdown("""
한글 문장을 두 가지 유형의 영어 표현으로 번역합니다.
> *Raw Expression*: 일상적이고 때로는 거친 표현을 포함한 실제 사용되는 표현
*Formal Expression*: 공식적인 상황에서 사용할 수 있는 정중한 표현
#
**사용 방법**
1. 입력창에 한글 문장을 입력하세요.
2. 원하는 표현 유형(Raw, Formal, 또는 둘 다)을 선택하세요.
3. 'Translate' 버튼을 클릭하세요.
4. 결과로 나온 영어 표현을 보고 들어보세요.
\* Raw Expression에는 비속어나 강한 표현이 포함될 수 있으니 주의하세요.
""")
# 입력창 설정
korean_sentence = st.text_input("한글 문장을 입력하세요: ") # 사용자가 한글 문장을 입력받는 텍스트 입력창
# 체크박스 설정
col1, col2 = st.columns(2) # 두 개의 열로 나누기
with col1:
use_pdf = st.checkbox("Raw Expression", value=True) # 첫 번째 열에 Raw Expression 체크박스
with col2:
use_formal = st.checkbox("Formal Expression", value=True) # 두 번째 열에 Formal Expression 체크박스
# 영어 표현 유형과 내용을 표시하는 함수 정의
def display_expression(expression_type, content):
st.markdown(f"<div class='divider'>", unsafe_allow_html=True) # 구분선 표시
st.markdown(f"<div class='expression-title'>🗨️ {expression_type}</div>", unsafe_allow_html=True)
# 표현 유형 제목 표시
col1, col2 = st.columns([7, 3]) # 표현 내용을 표시할 두 개의 열 설정
with col1:
st.markdown(f'<span class="highlight">{content[0]}</span>', unsafe_allow_html=True)
# 첫 번째 열에 강조된 표현 내용 표시
with col2:
audio_file = text_to_speech(content[0]) # 첫 번째 표현 내용을 음성으로 변환
st.audio(audio_file, format='audio/mp3') # 음성 파일 재생
for line in content[1:]: # 나머지 내용을 반복하여 표시
st.write(line) # 나머지 내용 표시
st.markdown("</div>", unsafe_allow_html=True) # 구분선 닫기
6) 'Translate' 버튼을 눌렀을 때 에이전트 실행, 화면에 출력
# 'Translate' 버튼을 눌렀을 때
if st.button("Translate"):
if korean_sentence: # 한글 문장이 입력되었는지 확인
# 사용자 선택에 따라 에이전트에게 지시 생성
if use_pdf and use_formal: # Raw Expression과 Formal Expression 둘 다 선택된 경우
user_instruction = f"'{korean_sentence}' 이 문장에 대해 Raw Expression과 Formal Expression을 모두 제공해주세요."
elif use_pdf: # Raw Expression만 선택된 경우
user_instruction = f"'{korean_sentence}' 이 문장에 대해 Raw Expression만 제공해주세요."
elif use_formal: # Formal Expression만 선택된 경우
user_instruction = f"'{korean_sentence}' 이 문장에 대해 Formal Expression만 제공해주세요."
else: # 아무 옵션도 선택되지 않은 경우
st.warning("적어도 하나의 옵션을 선택해주세요.") # 경고 메시지 표시
st.stop() # 실행 중지
# 에이전트 실행: 지시를 전달하고 결과 받기
result = agent_executor.invoke({"input": user_instruction})
output_lines = result['output'].split('\n') # 결과를 줄 단위로 분할
current_expression = None # 현재 표현 유형 초기화
expression_content = [] # 표현 내용을 저장할 리스트 초기화
for line in output_lines: # 결과의 각 줄을 반복
if line.startswith('* Raw Expression:') or line.startswith('* Formal Expression:'):
# 표현 유형을 나타내는 줄인지 확인
if current_expression: # 현재 표현 유형이 있는 경우
display_expression(current_expression, expression_content)
# 표현 내용을 화면에 표시
current_expression, english_sentence = line.split(':', 1) # 표현 유형과 영어 문장 분리
current_expression = current_expression.strip('* ') # 표현 유형 정리
english_sentence = english_sentence.strip() # 영어 문장 정리
expression_content = [english_sentence] # 표현 내용을 리스트로 초기화
elif line.strip(): # 빈 줄이 아닌 경우
expression_content.append(line) # 표현 내용에 추가
# 마지막 표현 출력
if current_expression: # 현재 표현 유형이 있는 경우
display_expression(current_expression, expression_content) # 표현 내용을 화면에 표시
else:
st.warning("한글 문장을 입력해주세요.") # 한글 문장이 입력되지 않은 경우 경고 메시지 표시
4. 사용 화면
시작 화면
‘쓸모없는’을 입력하고 ‘Translate’ 버튼을 눌러 실행한 화면과 PDF 원본 페이지
5. 정리
무엇보다도 데이터 전처리와 정규표현식의 중요성을 다시금 느꼈습니다. 데이터만 잘 정리되어 있다면 원하는 내용의 추출이 쉽고, 이후 작업의 효율이 증가합니다.
사용한 PDF 문서의 구조와 원하는 추출 형태에 맞추어 PyMuPDF + TF-IDF를 사용했습니다. 그 과정이 매우 복잡하고 어려웠으나, 결과적으로 정확하게 데이터 추출을 할 수 있게 되어 앱 제작을 마쳤습니다. 그렇다면 RAG로 사용할 문서 구조와 추출 목적에 맞추어 매번 데이터 처리를 하는 것이 답일까요? 좀 더 유연한 방법이 있는 것이 아닐까요?
사례를 작성할 때마다 이것이 최선인지 아닌지 모르는 상태에서 마무리를 하는 것이 아쉽습니다. 아직 모르는 부분이 너무 많기 때문이겠죠~ Langchain을 좀 더 깊이 알고 싶습니다. 다음엔 LangGraph를 이용해보려고요!
재미로 시작한 주제였는데, 비속어가 많이 등장해 사용 예시 동영상을 만들거나 스크린샷을 찍기가 힘드네요.😅 재미로 봐주시기 바랍니다~
#11기랭체인