현재 2주짜리 팀프로젝트로 LLM, RAG를 적용한 학습용 챗봇만들기 프로젝트를 하고 있다.
나는 모델 고도화를 담당했다.
이전에 해왔던 단순 정보 탐색용으로 RAG를 사용하는 것이 아닌 정보 탐색 + 문제 출제 기능을 수행할 수 있는 챗봇을 만들어야 했기 때문에 참고할 레퍼런스도 적었고 모델 성능이 최초에 생각했던 기능만큼은 나오지 않았던 어려움이 있었다.
저희가 설정한 데이터 셋은 스파르타 노션 교재였습니다. 그 중에서도 파이썬 라이브러리, 머신러닝, 딥러닝, LLM을 선정하였고 추가로 교재에는 실습용 코드가 부족하다는 판단을 해 해당 개념을 적용해 복습 학습을 할 수 있는 opensource를 선정해 데이터 셋으로 추가해두었습니다.
웹 페이지이기 때문에 단순히 WebBaseLoader로만 텍스트 추출로 가능할 것이라고 판단을 했는데
해당 교재의 보안을 살펴보니 링크를 가진 사용자만 접근할 수 있도록 제한되어 있었고 WebBaseLoader는 해당 경우에는 데이터를 가져오지 못합니다.
따라서 다른 방법을 택했어야 했습니다.
다음으로 테스트 했던 방법은 동적으로 텍스트를 추출하는 Selenium 방식입니다.
하지만 단순히 Selenium만을 사용했을 경우에는 로드되는 중간에 크롬창이 꺼지는 이슈가 있었습니다.
때문에 고도화 작업이 필요했습니다.
driver.get(url) # 페이지 로드
# 페이지 로드 대기
try:
WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.CSS_SELECTOR, '.notion-page-content'))
)
print("페이지가 완전히 로드되었습니다.")
except Exception as e:
print(f"페이지 로딩 실패: {url}. Error: {e}")
continue
# 토글이 닫혀 있으면 토글을 열기
try:
# 모든 토글 버튼을 찾음 (Ctrl+Alt+T에 해당하는 토글을 찾아서 열기)
toggle_buttons = driver.find_elements(By.XPATH, "//div[@role='button' and (@aria-expanded='false')]")
# 각 토글을 클릭하여 열기
for button in toggle_buttons:
button.click()
time.sleep(1) # 토글이 열리기 전에 잠깐 대기
# HTML 파싱 및 텍스트 추출
html_content = driver.page_source
soup = BeautifulSoup(html_content, 'html.parser')
# 텍스트 추출 및 전처리
raw_txt = soup.get_text()
def preprocess_text(txt):
# 1. '[스파르타코딩클럽]' 제거
txt = re.sub(r'\[?스파르타코딩클럽\]?', '', txt)
# 2. 저작권 문구 제거
txt = re.sub(r'Copyright.*$', '', txt)
# 3. \xa0를 공백으로 변환
txt = txt.replace('\xa0', ' ')
# 4. 정규식을 사용해 \\로 시작하는 LaTeX 명령어 제거
txt = re.sub(r'\\[a-zA-Z]+\{.*?\}', '', txt)
# 5. \command{...} 형식 제거
txt = re.sub(r'\\[a-zA-Z]+', '', txt)
return txt
다들 알고 있듯이 전처리 부분에는 모델의 성능이 달라질 수 있기 때문에 가장 깔끔하고 불필요한 수식어구는 최대한 들어가지 않아야 하는 부분이었습니다.
주의했어야 했던 부분은 특수문자 제거 코드 부분이었는데 교재 특성상 ? = + 같은 특수 문자들이 코드에서는 의미있게 사용되었기 때문에 제거해서는 안되었습니다.
불필요한 문구를 제거하는 문구를 제거하는 1번과 2번은 넘어가고 3번을 보면 특수 공백 문자를 공백으로 대체하는 부분이 나옵니다.
특수공백 문자란 일반 공백(' ')과 다르게 줄 바꿈이 발생하지 않도록 설계된 공백입니다. 때문에 의도가 공백임을 알 수 있습니다. 또한 해당 부분을 전처리 하지 않고 넘어가게 되면 이후 split()를 할 경우에 해당 부분을 공백으로 인식을 하지 못하고 제대로 split()하지 못하는 이슈가 발생할 수 있기 때문에 공백으로 대체하고 이어나갔습니다.
그다음은 LaTex 명령어 삭제 부분입니다. (4번, 5번)
LaTeX 명령어는 문서의 포맷(글씨체, 스타일, 수식 등)을 정의하거나 특정 작업을 수행하도록 작성되지만, 텍스트 데이터로만 사용할 때는 불필요합니다.
저희 서비스는 사용자가 먼저 질문하는 것이 아닌
질문 생성 챗봇이 질문 생성 -> 사용자 답변 -> 답변 피드백 -> 사용자 추가 질문 -> 질문 답변 플로우로 동작하게 됩니다.
최초의 질문 생성 Rag모델은
기본적으로 텍스트 데이터를 벡터로 변환한 후 유사도 기반 검색을 진행하는
Chroma retriever를 사용하며
프롬프팅은 아래와 같았습니다.
당신은 AI 강사입니다. 아래 context를 기반으로 하나의 퀴즈를 만들어 사용자의 대답을 기다리세요.
퀴즈는 보기가 있는 객관식 또는 O,X 형태로 출제해주세요. (주로 코드 내용과 관련된 문제를 추천합니다.)
이후, 사용자의 대답을 확인하고 아래 형식을 바탕으로 피드백을 제공하세요:
- 정답 여부: "N번" 또는 "예/아니오"
- 추가 설명: (정답과 관련된 추가 정보를 제공하세요)
Context: {context}
rag_chain 구성은 단순히 retriever로 갖고 온 문맥을 LLM에 넣어주는 형식이었습니다.
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
rag_chain = (
{"context": retriever | format_docs}
| prompt
| llm
| StrOutputParser()
)
초기 모델의 문제점은 아래와 같았습니다.
첫 번째 질문 이후 질문들의 개념이 모두 첫 번째 질문과 똑같거나 유사한 질문을 생성한다는 점이었습니다.
해결 과정 1
가장 먼저로는 cosine 유사도로 검증한 유사한 질문이 나올 때에는 질문으로 생성하지 말고 유사하지 않은 질문들만 생성되도록 구현을 했습니다.
하지만 무한루프를 도는 문제점이 있었습니다.
즉, retriever에서 계속해서 같은 문맥만을 갖고 온다는 말이죠.
해결 과정 2
때문에 retriever가 가져오는 문맥을 교재마다 갖고 오도록 수정했습니다.
해결 과정 3
기존에 사용했던 Chroma retriever에서 retriever 가 가져오는 context속 단어들 가운데 무시되는 경우가 있어서 질문을 제한적으로 만드는 경우를 발견하게 되었습니다.
때문에 context 속 단어들도 가중을 두어 가져오는 방식인 bm25 retriever에 유사도 기반 retriever인 FAISS와 5대 5로 결합하여 EnsembleRetriever로 만들어 retriever를 업그레이드 했습니다.
해결 과정 4
프롬프팅 고도화 1
당신은 AI 강사입니다.
아래의 {context} 안에서만 반드시 **한국말**로 된 단, 하나의 질문을 생성해주세요.
(최대한 코드에 관한 시나리오적 질문이면 더 좋습니다.)
{quiz_list}에 존재하는 질문들과는 최대한 덜 유사한 질문을 생성해주세요.
아래의 제약 조건과 출제 방식에 맞춘 질문을 생성해주세요.
제약 조건:
1. "Context"에서 제공된 내용만 기반으로 질문을 생성하세요.
2. AI 관련 내용이 아닌 질문은 생성하지 마세요
3. "QuizList"에 이미 있는 질문과 유사하지 않은 새로운 질문을 생성하세요.
출제 방식:
- 질문은 반드시 보기가 있는 객관식(MCQ) 또는 O,X 형태로 출제하세요.
- "Context"에 명시적으로 언급된 개념, 정의, 또는 내용을 활용하세요.
- 질문은 반드시 질문 내용만 담겨야 합니다. 정답을 포함하지마세요.
- 질문 내용에는 "quiz:" 나 "질문:" 같은 불필요한 수식어구는 담겨서는 안됩니다.
Context:
{context}
QuizList:
{quiz_list}
프롬프팅 고도화에서 시간이 꽤 걸렸는데 retriever를 문맥 단위로 하고 ensemble로 바꾸어도 종종 유사한 질문을 하는 경우가 있었습니다. 때문에 애초에 질문을 생성하는 프롬프트에 명시적으로 이전 질문을 넣어주어 LLM쪽에서도 프롬프팅에서 이전 질문들과 겹치는 질문을 하지 않도록 고도화를 했습니다.
이때 프롬프트에 넣어주는 질문은 같은 세션에서 질문에 해당하며 질문을 rag_chain이 내뱉게 되면 그 즉시 저장되는 txt파일이 있는데 새롭게 질문을 하는 api를 호출하게 되면 세션넘버에 맞춰 해당하는 세션의 이전 질문 txt파일들을 갖고 오게 됩니다.
따라서 같은 세션에서는 다른 질문을 반드시 생성하고 다른 세션의 경우에는 같은 질문을 생성할 수도 있게 구현을 했습니다. 해당 기능으로 도전 과제 1을 구현했다고 생각합니다.
프롬프팅의 구성을 살펴보면
이전 프롬프트와 다르게 단호한 어조로 프롬프트를 넣어주니 프롬프트가 제약조건을 위배하는 질문을 생성하지 않게 되었습니다.
또 퀴즈형 서비스에 맞는 조건은 출제 방식에 넣어두어 서비스적인 부분을 개선했습니다.
해결 과정 5
교재에 대한 RAG 모델의 성능은 원하는 결과만큼 나왔지만 opensource에 대한 질문 생성에는 문제가 존재했습니다.
opensource의 데이터셋 특성상 설명보다는 코드 부분의 비중이 높은데 질문만 생성하고 그에 대한 코드를 제시해주지 않아 질문만으로는 정답을 맞출 수 없는 이슈가 있었습니다.
그래서 프롬프트를 새로 만들게 되었는데 해당 프롬프트가 opensource 질문은 제대로 생성하는 반면 이전의 교재 데이터 셋에서는 개념 파트만 있는 교재에 대해서는 질문을 생성하지 못하는 이슈가 생겼습니다.
때문에 opensource전용 프롬프트와 일반 교재 전용 프롬프트를 구분해서 사용하는 방식을 택했습니다.
추가로 opensource의 경우 간헐적으로 코드에 대한 설명을 빠트리는 질문들이 존재했기 때문에 이를 명시적으로 코드 부분만을 표시해주는 chain을 하나 더 달아서 질문을 개선했습니다.
open_source_prompt = ChatPromptTemplate.from_messages([
("system", """
당신은 AI 강사입니다.
아래의 {context} 안에서만 반드시 **한국말**로 된, 이전에 물어봤던 문제와 유사하지 않은
단 하나의 질문을 생성해주세요.
반드시 질문은 질문 내용으로 명확히 답할 수 있는 질문이어야 합니다.
질문을 만들 때에는 코드와 관련된 특정 동작이나 목적에 대해 물어야 하며, 질문 안에 반드시 **코드를 포함**해야 합니다.
되도록이면 코드의 시나리오적 질문을 생성해주세요.
에시 코드는 질문에 포함하지마세요.
예시:
코드:
dataloader = DataLoader(dataset, batch_size=32, shuffle=True)
이 코드에서 이미지 데이터를 불러올 때 한 번에 32개의 이미지를 불러오나요? (O/X)
[중요]
아래의 금지리스트들과 유사한 질문을 절대 생성하지 마시오.
금지리스트: 이전에 만들었던 질문, "QuizList"
주관적인 변수에 대한 의견을 묻는 질문은 절대 생성하지 마세요.
예를 들어, "타이타닉 데이터셋의 'embarked' 열을 숫자로 매핑할 때, 'S'는 어떤 숫자로 매핑되나요?", "2016년 이후의 데이터를 사용했을 때 r2 score와 mse가 더 좋은 점수를 보였다 (O, X 질문)"와 같은 질문은 피해야 합니다.
개발 외의 역량을 붇는 질문은 절대 생성하지 마세요.
예를 들어, "위험중립형 투자자는 kodex골드, tiger나스닥100, kodex 삼성그룹을 각각 몇 개 매수하는 것이 추천되나요?"와 같은 질문은 피해야 합니다.
질문에서 제공된 정보로 명확히 답할 수 없는 질문만 절대 생성하지 마세요.
예를 들어, "데이터가 증가할수록 모델의 평가지표가 안 좋아지는 이유는 무엇인가요? (O, X)", "이미지 데이터셋을 불러올 때 한 번에 몇 개의 이미지를 불러오나요? (a) 16개 (b) 32개 (c) 64개 (d) 128개"와 같은 질문은 피해야 합니다.
질문의 형태는 반드시 객관식 또는 OX 질문 형태여야만 합니다. (어떤, 무엇을 묻는 질문은 생성하지 마세요.)
예를 들어, "인스턴트 초기화에 쓰이는 생성자에 __call__ 메소드를 호출하면 어떤 값이 반환되나요? (O/X)"과 같은 질문은 피해야 합니다.
예를 들어, 주관식과 OX 질문이 결합되거나 객관식과 OX 질문이 결합된 형태는 절대 생성하지 마세요.
또한, 아래의 제약 조건과 출제 방식에 맞춘 질문을 생성해주세요.
제약 조건:
1. "Context"에서 제공된 내용만 기반으로 질문을 생성하세요.
2. AI와 관련된 질문만 생성하세요.
3. 질문의 형태는 객관식(MCQ) 또는 O,X 형태여야 합니다.
4. 질문은 반드시 질문 내용만 담겨야 합니다. "quiz:" 나 "질문:" 같은 불필요한 수식어구는 붙이지 마세요.
5. 질문에서 제공된 정보로 명확히 답할 수 있는 질문만 생성하세요.
출제 방식:
- 질문은 반드시 객관식(MCQ) 또는 O,X 형태로 출제합니다.
- "Context"에 명시적으로 언급된 개념, 정의, 또는 내용을 활용하세요.
Context:
{context}
QuizList:
{quiz_list}
""")
])
discription_prompt = ChatPromptTemplate.from_messages([
("system", f"""
quiz에 대해 정답을 찾을 수 있는 부분을 context에서 찾아서 한국말로 보여주세요.
되도록이면 context 중에서도 코드 부분을 보여주세요.
단, quiz와는 내용이 정확하게 일치하는 부분은 제외해주세요.
찾을 수 없다면 아무것도 출력하지 마세요.
[주의]
코드외의 정답에 대한 직접적인 설명적인 힌트는 포함시키지 마세요.
quiz : {{quiz}}
context : {{context}}
""")
])
답변 피드백 api를 호출하게 되면 파라미터로 세션 넘버가 넘어오게 되는데 해당 세션 넘버로 답변과 피드백 txt파일의 세션 넘버를 일치해서 생성될 답변 txt와 피드백 txt의 파일명이 명시되게 됩니다.
피드백 프롬프트의 구성으로는
정답 여부 뿐만 아니라 추가 설명도 제시해주게 구성을 해 사용자의 학습 효과를 높이고자 하였습니다.
AI 강사로서 다음 퀴즈의 정답 여부를 확인하고 한국말로 피드백을 제공하세요.
피드백은 아래와 같은 형식이어야 합니다:
- 정답 여부: "N번" 또는 "예/아니오"
- 추가 설명: (정답과 관련된 추가 정보를 제공하세요)
퀴즈 : {{quiz}}
답변 : {{user_answer}}
피드백을 받게 되었는데 해당 피드백 만으로 더 추가 학습이 필요한 경우도 있기 때문에 일반 LLM을 연결해두어 추가 학습을 진행할 수 있게 개선했습니다.
이미 프롬프팅이 완료된 상태에서 chain의 input으로 language를 추가하게 되면 질문 자체를 번역하는 것이 아닌 답을 한 후 번역하는 이슈가 있었습니다.
때문에 chain의 성능을 저해하는 input 값 추가가 아닌 LLM이 생성한 질문 자체를 번역만하는 기능을 하는 DEEPL의 API를 가져와 번역하는 기능을 추가해두었습니다.