퍼스널 페이지에 LLM 연동하기 1편 - 지식베이스 자동 구축
나는 원래 개인 프로필을 노션에 정리해두고 있었다. 사실 프론트엔드나 웹 개발에는 큰 흥미도, 여유도 없었기 때문에 빠르게 쓸 수 있는 도구를 선호했기 때문이다. 하지만 이번에 연구실에 진학하면서 사정이 달라졌다. 연구 경험과 논문 실적이 자연스럽게 드러나면서도 깔끔한 구조의 퍼스널 페이지가 필요했기 때문이다.
나는 웹 개발 경험이 거의 없는 상태였기 때문에, 빠르고 안정적으로 정적 웹사이트를 만들 수 있는 방법을 찾다가 Jekyll을 선택했다. Markdown 파일만 잘 관리하면 사이트가 자동으로 생성되고, GitHub Pages를 통해 손쉽게 배포할 수 있는 점이 특히 마음에 들었다. 처음에는 이 정도로도 충분하다고 생각했다.

그런데 문득 이런 생각이 들었다. 나는 최근 LLM을 이용한 HCI 연구를 많이 하고 있고, 당분간은 이 방향을 나의 아이덴티티로 삼고자 하고 있다. 그렇다면, 내 포트폴리오 페이지에 LLM을 붙인다면 그게 곧 나의 연구 관심사를 가장 직접적으로 보여줄 수 있는 좋은 데모가 되지 않을까?
이 프로젝트는 바로 그 생각에서 출발했다.
LLM 챗봇 구조
내가 만들고자 한 챗봇은 단순히 "GPT-4에게 아무거나 물어보는" 구조가 아니다. 방문자의 질문에 대해 내 페이지 안의 콘텐츠를 검색해서 적절한 정보를 바탕으로 GPT가 대답하도록 만드는 RAG(Retrieval-Augmented Generation) 구조를 도입하고자 했다.
RAG 챗봇은 다음의 세 가지 구성 요소로 이루어진다.
- 지식베이스 구축: 내 페이지의 Markdown 문서와 논문 등의 텍스트를 벡터화하여 저장
- 질문-응답 API 서버: 질문을 받아 지식베이스에서 관련 정보를 검색하고 GPT에 전달
- 프론트엔드 인터페이스: 질문을 입력하고 응답을 보여주는 챗 UI
이번 글에서는 첫 번째 단계인 지식베이스 구축과 자동 업데이트를 다룬다. (글에서는 아주 간단한 PoC만 다룰 예정이다. 고도화는 필수!)
Markdown 기반 콘텐츠 수집
내 사이트는 Jekyll을 기반으로 하고 있으며, 페이지 내용은 대부분 Markdown 파일 형태로 작성되어 있다. 자기소개, 학력과 경력, 프로젝트, 논문 리스트 등은 _pages, _posts, _data 등의 디렉토리에 .md 파일로 저장되어 있다.
이 파일들에서 텍스트를 추출하기 위해 python-frontmatter 라이브러리를 사용했다. 이 라이브러리를 이용하면 YAML 메타데이터와 본문 내용을 쉽게 분리할 수 있다.
import frontmatter
import glob
def load_markdown_texts(path):
md_files = glob.glob(f"{path}/**/*.md", recursive=True)
texts = []
for file in md_files:
try:
with open(file, 'r', encoding='utf-8') as f:
post = frontmatter.load(f)
content = post.content.strip()
if content and len(content) > 50: # 너무 짧은 문서 제외
texts.append({"text": content, "source": file})
except Exception as e:
print(f"[!] Failed to load {file}: {e}")
return texts
이렇게 긁어온 텍스트는 LLM에게 넣어줄 수 있는 형태로 청킹을 수행한 후 FAISS 벡터 데이터베이스로 저장해준다.
import os
import openai
import numpy as np
import faiss
openai.api_key = os.getenv("OPENAI_API_KEY")
def chunk_text(text, max_tokens=1000):
return [text[i:i+max_tokens] for i in range(0, len(text), max_tokens)]
def get_embedding(text):
try:
response = client.embeddings.create(
model="text-embedding-ada-002",
input=[text]
)
return np.array(response.data[0].embedding, dtype="float32")
except Exception as e:
print(f"[!] Embedding failed: {e}")
return None
def build_index(embeddings, metadata_list):
if not embeddings:
print("[!] No embeddings to index.")
return
dimension = len(embeddings[0])
index = faiss.IndexFlatL2(dimension)
index.add(np.array(embeddings))
# 인덱스 저장
faiss.write_index(index, "vector.index")
# 메타데이터 저장
with open("metadata.json", "w", encoding="utf-8") as f:
json.dump(metadata_list, f, ensure_ascii=False, indent=2)
이제 임베딩이 완료되었다. 이 임베딩을 바탕으로 제대로 응답이 가능한지 간단하게 확인을 해보도록 하자.
import os
import openai
import numpy as np
import faiss
import json
from update_knowledge_base import get_embedding
client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
def get_embedding(text):
try:
response = client.embeddings.create(
model="text-embedding-ada-002",
input=text
)
return np.array(response.data[0].embedding, dtype="float32")
except Exception as e:
print(f"[!] Embedding 오류: {e}")
return None
def load_index_and_metadata(index_path="vector.index", metadata_path="metadata.json"):
index = faiss.read_index(index_path)
with open(metadata_path, "r", encoding="utf-8") as f:
metadata = json.load(f)
return index, metadata
def search_similar_docs(query, index, metadata, top_k=3):
query_embedding = get_embedding(query)
if query_embedding is None:
return []
query_embedding = np.expand_dims(query_embedding, axis=0)
distances, indices = index.search(query_embedding, top_k)
return [metadata[i]["text"] for i in indices[0] if i < len(metadata)]
def generate_answer(query, context_texts):
prompt = (
"다음 문서를 참고하여 질문에 답해줘.\n\n"
"문서:\n" + "\n---\n".join(context_texts) +
f"\n\n질문: {query}\n답변:"
)
try:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "너는 친절한 블로그 도우미야."},
{"role": "user", "content": prompt}
],
temperature=0.7
)
return response.choices[0].message.content.strip()
except Exception as e:
print(f"[!] Chat 생성 오류: {e}")
return "답변을 생성하는 데 실패했습니다."
def ask(query):
index, metadata = load_index_and_metadata()
context_texts = search_similar_docs(query, index, metadata)
if not context_texts:
return "관련 문서를 찾을 수 없습니다."
answer = generate_answer(query, context_texts)
return answer
if __name__ == "__main__":
query = "이 사람의 현재 어떤 일을 하고 있나요? 주요 관심사는 무엇인가요?"
print(f'질문: {query}')
print(f'답변: {ask(query)}')

조금 응답이 어색하긴하지만.. 뭐 좋다. 어차피 고도화는 한참 더 해야하니까.. 기본적인 벡터 데이터베이스 구축 코드는 작성이 완료되었다.
GitHub Action으로 임베딩 자동화하기
현재의 방법은 블로그 내용을 사전에 벡터 데이터베이스화 해두고 이를 바탕으로 답변을 하고 있다. 이 경우 깃허브 블로그 내용이 바뀐다면 우리는 임베딩 작업을 다시 해주어야 한다. 이 작업을 매번 수동으로 하기에는 귀찮으니 GitHub Actions를 이용해 자동화를 할 수 있도록 해보자.
name: Update Knowledge Base
on:
push:
paths:
- '_pages/**'
workflow_dispatch:
jobs:
update:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: |
pip install -r requirements.txt
- name: Run embedding pipeline
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python _scripts/update_knowledge_base.py
- name: Upload results
uses: actions/upload-artifact@v3
with:
name: knowledge-base
path: |
vector.index
docs.txt
이제 깃헙 블로그의 주요 내용을 담당하는 부분인 '_pages' 내의 .md 파일이 변경되면 GitHub Action 워크플로우가 자동으로 실행되어서 Knowledge-base의 업데이트를 수행할 것이다.
여기까지 기본적인 세팅을 진행해 보았다. 다음에는 이 지식베이스를 바탕으로 주어진 질문에 답변하는 LLM 챗봇을 구현해 보도록 하겠다. 근데 연구가 바빠져서 언제 다시 시작할지는 미지수다..
'개인공부&프로젝트' 카테고리의 다른 글
퍼스널 페이지에 LLM 연동하기 2편 – Astro에 챗봇 연결하기 (feat. Gemini) (1) | 2025.04.05 |
---|---|
왜 RNN보다 트랜스포머가 좋다는걸까? (feat. 혁펜하임의 Easy! 딥러닝) (0) | 2025.02.15 |
PARA 프레임워크로 생산성 관리하기 (feat. LLM Agent) (0) | 2025.02.15 |
GPU 병렬화 기법 (0) | 2025.02.04 |
디퓨전 모델과 ELBO 정리: DDPM 논문을 중심으로 (0) | 2025.02.01 |
댓글