이번 주는 유독 잠에서 깨는 게 힘들었다. 하루는 원래라면 수업 시작 30분 전에 도착할 수 있는 시간에 출발했지만, 버스에서 잠에 들어 내려야 할 곳보다 더 많이 이동해서 하마터면 지각할 뻔했다. 최근 새벽 12시에서 1시 사이에 잠에 들고 있는데, 잠이 부족한 걸까. 아니면 갑자기 찾아온 더위 때문에 그런 걸까. 분명 정신이 나태해지진 않았는데, 그렇다면 일찍 잠들고 아예 일찍 나와서 수업 시작 전 1시간 정도 복습을 진행하는 게 나을까. 해결책을 강구해야겠다. 나는 지각하는 내가 제일 싫단 말이야...
💻 What?
- 6월 9일 (월): Prompt Template과 Output Parser에 대해 배웠다. Prompt Template과 LLM, Parser를 Chaining하여 원하는 포맷의 답변을 얻는 실습을 진행했다.
- 6월 10일 (화): 전날 가볍게 다뤄 본 Chain에 대해 배웠다. 여러 Chain 관련 클래스 기반 객체 생성 방식의 Off-the-shelf Chain과 표현식으로 간단히 Chain을 정의하는 LCEL(LangChain Expression Language)에 대해 배웠다. Off-the-shelf 방식은 권장하지 않는다. 그리고 Chain을 구성하는 여러가지
Runnable클래스에 대해 배웠다. 수업이 끝난 뒤 코딩테스트 스터디에서 자료구조Heap과Priority Queue에 대해 알아보는 시간을 가졌다. - 6월 11일 (수): Chain을 구성하는 요소들을 커스터마이징하는 방법을 배웠다. 함수를 정의한 뒤 데코레이터
@chain으로 감싸주면RunnableLambda객체가 되어, 바로 Chain에 결합할 수 있었다. 또한 (글자 단위로)동일한 질문을 받으면 저장해 둔 답장을 불러오게 도와주는Cache클래스, 질의와 응답을 데이터베이스에 저장하여 모델이 맥락을 기억하고 이에 맞는 답변을 하게 도와주는MessageHistory클래스에 대해 배웠다. 수업 말미에streamlit으로 Memory 기능을 탑재한 gpt-4o-mini 기반 Chatbot을 구현하는 과제를 부여받았다. - 6월 12일 (목): RAG(Retrieval Augmented Generation 수업이 시작되었다. 작동 방식은 크게 1. Indexing과 2. Retrieval & Generation으로 나눠지는데, 오늘은 Indexing의 첫 번째 단계인 Data Loading에 대해 배웠다.
langchain_community.document_loaders모듈 내의 여러 Loader를 다뤄보는 시간을 가졌다. - 6월 13일 (금): Indexing의 두 번째 단계인 Chunking과 그 다음 단계인 Vector DB에 대해 배웠다.
CharacterTextSplitter와RecursiveCharacterTextSplitter의 여러 parameter들을 만져보며 Chunking을 실습하고,InMemoryVectorStore를 사용하여 Chunk들을 DB에 담는 실습을 통해 Vector DB를 찍먹했다. 강사님이 제공해주신.txt파일 한 개를 Loading&Chunking한 뒤 Vector DB에 담는 실습 과제를 부여받은 뒤 이번 주 수업이 마무리됐다.
😮 So What?
이번 주는 실습 거리가 많아 재미있었다. GPT API를 이용한 간단한 Chatbot 실습과 간단한 Vector DB 실습을 할 수 있었는데, 각 실습에서의 애로사항 이야기를 해보려 한다.
Chatbot_memory
streamlit으로 구현했기 때문에 streamlit.session_state를 활용할 수 있었다. 강사님은 session_state에 {'messages':[{'role':'user','content':'chatting'}, {'role':'ai','content':'chatting'}, ...]}의 형식으로 채팅 내역을 저장하고, 이를 히스토리로 이용하는 방식, 그리고 session_state의 session_id라는 key에 사용자 id를 입력받아 사용자 별로 메모리를 개인화하여 관리하는 방식으로 설명해주셨다. 강사님이 가르쳐주시기 전 내가 실습한 방식은 후자의 방식이었기 때문에, RunnableWithMessageHistory 클래스의 객체를 만들어 session_id 별로 채팅 내역을 관리했다.
# Prompt Template, Model, History Runnable 정의하기
@st.cache_resource
def get_runnable():
load_dotenv()
model = ChatOpenAI(model_name = 'gpt-4o-mini')
def get_session_history(session_id):
if session_id not in st.session_state['storage']:
st.session_state['storage'][session_id] = InMemoryChatMessageHistory()
return st.session_state['storage'][session_id]
prompt_template = ChatPromptTemplate([
('system', '당신은 AI 전문가입니다. 질문에 한국어로 답변해주시되, 전문 용어들을 영문 원어 그대로 사용해주세요.'),
MessagesPlaceholder(variable_name='history', optional = True),
('human', '{query}')
])
chain = prompt_template | model
return RunnableWithMessageHistory(
runnable = chain,
get_session_history = get_session_history,
input_messages_key = 'query',
history_messages_key = 'history'
)
chain = get_runnable()
그러나 문제가 발생했다. 채팅을 거듭할 때마다 Model의 답변 채팅만 사라지는 것이다. 아래 코드가 원인이었다.
prompt = st.chat_input("User Prompt")
if prompt:
# User Prompt 저장 및 출력
# ...생략...
# Model Response 출력 및 저장
with st.chat_message('ai'):
message_placeholder = st.empty() # Updatable Container
full_message = ""
output_generator = chain.stream({'query':prompt}, {'configurable':{'session_id':'userName'}})
st.write_stream(output_generator)
for token in output_generator:
full_message += token.content
st.session_state["messages"].append({"role":"ai", "content":full_message})
chain.stream()실습 전 수업 시간에 stream 형태로 메시지를 출력하는 방식을 배웠다. 이는 반복문을 통해 Generator에서 뽑아온 chunk를 str 타입 변수에 누적해서 저장하고 그 결과를 매 반복마다 뿌려주는 방식이었다.
'벌써' --초기화--> '벌써 12주차' --초기화--> '벌써 12주차라니.'
다른 방식이 없나 싶어서 나는 streamlit 모듈을 열고 코드 한 줄로 Generator의 모든 chunk들을 stream 형식으로 일괄 출력해주는 함수가 있는지 찾아봤다. 아니나 다를까 streamlit.write_stream(stream: 여러가지)라는 함수가 있었는데, Callable, Geneartor, Iterable, OpenAIStream, LangChainStream을 argument로 받고 한 번에 stream해주는 함수였다. 바로 이게 문제였다. Generator의 chunk는 한번 yield하면 해당 객체 안에서 영영 사라진다. 위의 코드는 chunk가 하나도 없는 빈 Generator에서 빈 메시지를 받아 히스토리에 저장하고 있는 꼴이었다. 그래서 나는 바로 강사님이 알려주셨던 방법으로 수정했다.
# Model Response 출력 및 저장_수정
with st.chat_message('ai'):
message_placeholder = st.empty() # Updatable Container
full_message = ""
for token in chain.stream({'query':prompt}, {'configurable':{'session_id':'userName'}}):
full_message += token.content
st.write(full_message)
st.session_state["messages"].append({"role":"ai", "content":full_message})
정말 오랜만에 만나서 그런지 Generator의 특성이 생각나지 않았고, 30분이 넘도록 전혀 관련 없는 부분을 수정했었다. 나중에 이런 일이 있었다고 강사님께 말씀드리니 이렇게 문서들 스스로 찾아보면서 익히면 실력이 금방 느니 슬퍼하지 말라고 위로해주셨다. 역시 기본기가 중요하다는 것을 느꼈다.
Vector DB
수업 시간에 강사님이 우리 데이터와 모델에 잘 맞는 Embedding Model을 채택하는 것이 중요하다고 설명해주셨다. 이를 바탕으로 나도 여러가지 모델을 써보고 싶어졌다. 그렇게 처음으로 써본 모델은 Google의 embedding-001이었다.
from langchain_google_genai import GoogleGenerativeAIEmbeddings
embedder = GoogleGenerativeAIEmbeddings(model = 'models/embedding-001')
이후 .txt 파일을 불러오고, chunk 단위로 쪼개주었다. 파일이 작아서 chunk size를 70으로 설정하니 거의 한 문장씩 split되었다.
from langchain_community.document_loaders import TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
loader = TextLoader('data/olympic.txt', encoding = 'utf-8')
# list[Document]
chunks = loader.load_and_split(
text_splitter = RecursiveCharacterTextSplitter(
chunk_size = 70,
chunk_overlap = 20
)
)
그 다음 In-memory Vector DB에 embedding-001 모델로 벡터화한 chunk들을 저장했다. 여기에서 add_documets()와 from_documents()가 헷갈려 오류가 발생했었다. 찾아보니 from_documents()는 실행과 함께 객체 생성 및 vector 저장을 수행하는 Class Method, add_documents()는 기존 객체에 vector를 저장하는 Instance Method였다. 나의 경우 생성은 생성대로 한 뒤에, 이미 존재하는 객체에 from_documents를 실행하니 오류가 발생한 것이었다.
from langchain_core.vectorstores import InMemoryVectorStore
vector_store = InMemoryVectorStore(embedding = embedder)
vector_store.add_documents(
documents = chunks, # Document
)

만약 from_documents()를 사용하려면 아래와 같이 작성하면 되겠다.
# Class Method
vector_store = InMemoryVectorStore.from_documents(
documents = chunks,
embedding = embedder
)
마지막으로 query 문장을 입력받아 벡터화 한 뒤, chunk들과 유사도 비교 후, 가장 비슷한 5개의 chunk를 출력하는 코드를 작성했다.
while True:
query = input()
if query == "!q":
break
top_5_sim = vector_store.similarity_search_with_score(
query = query,
k = 5
)
for idx, doc in enumerate(top_5_sim, 1):
print(f"{idx:<3} {doc[0].page_content} similarity: {round(doc[1], 4)}")
저렇게 비교해보니 대부분의 결과가 60~70%의 유사도를 보였다. 처음인지라 이게 어느 정도 수준인지 아직 모르겠다. 주말 동안 Splitter 객체 parameter도 만져보고, 여러 Embedding 모델도 사용해 보면 답변을 얻을 수 있을 것 같다.
🎈 Now What?
남은 주말은 앞서 말했듯 실습 코드를 최적화하는 시간이 될 것이다. 어느 정도의 만족스러운 결과가 나왔다면, 다음 코딩 테스트 스터디 때 사용할 자료를 만들어야겠다. 다음 주의 테마는 DFS/BFS이다. 나도 처음 익히는 알고리즘인 만큼, 잘못된 정보를 전달하지 않도록 여러 번 검수해야겠다.
쓰다 보니 벌써 12주, 세 달에 가까운 시간이 흘렀다. 지금까지 여러가지 기술을 익히며 느낀 건 두 가지가 있다. 새로운 지식을 습득하는 건 항상 즐겁다는 것과, 뭐든 재미있어 보여서 내가 어떤 분야에서 깊이를 쌓아야 할 지 고민된다는 것이다. 그래서 한 가지를 결심했다. 지금까지 캠프에서 배운 것들, 그리고 커리큘럼 상 간단하게 짚고 넘어가는 것들을 이용하여 미니 프로젝트를 진행할 것이다. 남은 세 달동안 실현 가능한 수준에서 여러 가지 분야를 미니 프로젝트를 통해 찍먹해보면 내가 재미를 느끼면서도 잘 하는 일이 무엇인지 쉽게 찾을 수 있으리라. But easier said than done. 말은 쉽지만 남은 시간을 고려하면 마냥 쉬운일이 아니다. 남다른 각오로 임해야겠다. 첫 번째 프로젝트의 주제는 이미지 혹은 영상 데이터 처리가 될 것이다. 시작하기 전에 별도로 공부하는 시간을 1~2주 정도 가져야겠다.
비전공자도 비전공자 나름이라는 걸 꼭 보여주고 싶다. 독기를 품자.
여담

목요일 저녁, 미니 데이트로 문래동에 나섰다. 일본식 튀김 덮밥을 먹은 뒤 로프트하우스라는 카페로 발걸음을 옮겼다. 갈 때마다 웨이팅 이슈로 이용하지 못했었는데, 이번에는 운 좋게 자리를 잡을 수 있었다(아니나 다를까 다 마시고 일어날 때는 4, 5팀이 웨이팅을 걸어 놓고 있었다). 루프탑이 유명한 카페인데 날씨가 너무 더워 실내로 대피했다. 오늘은 웬일로 둘 다 밀크 커피를 주문했다. 한 잔은 플랫 화이트, 한 잔은 시그니처 커피인 로프트 커피였는데, 둘 다 너무 맛있게 잘 먹었다. 사실 원두를 선택할 수 있었는데, 그 날은 먹고 싶었던 원두가 소진되어 선택의 여지가 없어 살짝 아쉬웠다. 아이스크림이 올라간 카라멜라이즈드 브리오슈도 주문했는데, 이게 우리가 시킨 커피랑 조합이 너무나도 훌륭했다. 나중에는 창가석에 앉아서 잠깐 작업도 해보고 싶다.
'SKN_13th' 카테고리의 다른 글
| [플레이데이터 SK네트웍스 Family AI 캠프 13기] 14주차 회고 (5) | 2025.07.01 |
|---|---|
| [플레이데이터 SK네트웍스 Family AI 캠프 13기] 13주차 회고 (2) | 2025.06.22 |
| [플레이데이터 SK네트웍스 Family AI 캠프 13기] 11주차 회고 (7) | 2025.06.08 |
| [플레이데이터 SK네트웍스 Family AI 캠프 13기] 월간 회고: 5월 (0) | 2025.05.31 |
| [플레이데이터 SK네트웍스 Family AI 캠프 13기] 9주차 회고 (0) | 2025.05.25 |