LangChain으로 내 티스토리 블로그를 학습한 RAG 챗봇 만들기

들어가며
요즘 채용 공고를 보면 RAG, LangChain, 문서 기반 AI 같은 키워드가 심심찮게 보인다. 개념은 대충 알겠는데 직접 만들어본 적은 없어서, 이론만 아는 상태가 영 찜찜했다. 그래서 날 잡고 프로젝트를 하나 만들어보기로 했다.
주제는 간단하다. 내가 그동안 쓴 티스토리 블로그 글을 학습해서, 질문하면 내 글을 근거로 답해주는 챗봇. 마침 블로그에 글이 꽤 쌓여 있으니 데이터도 이미 있는 셈이었다.
RAG가 뭐길래
RAG(Retrieval-Augmented Generation)를 한 문장으로 줄이면 이렇다. "먼저 관련 문서를 찾아오고(Retrieval), 그 문서를 근거로 답을 생성한다(Generation)."
일반 챗봇은 모델이 학습한 지식으로 답한다. 그래서 내 블로그 내용은 당연히 모른다. 반면 RAG 챗봇은 답하기 전에 내 블로그에서 관련 글을 검색해 와서, "이 내용을 근거로 답해"라고 시킨다. 그 덕분에
- 내 데이터(블로그 글)에 대해 답할 수 있고
- 근거가 된 출처를 같이 보여줄 수 있고
- 모르는 건 지어내지 않고 "관련 내용이 없다"고 답하게 만들 수 있다
마지막 항목이 특히 중요하다. 아는 척으로 없는 말을 지어내는 걸(환각) 구조적으로 줄여주는 게 RAG의 핵심이다.
전체 구조
코드는 두 파일로 나눴다.
[색인] ingest.py → 블로그 글을 벡터로 만들어 저장 (한 번만 실행)
[질문] query.py → 질문마다 관련 글을 검색해서 답변 (계속 실행)
RAG가 원래 "미리 문서를 저장해두는 단계"와 "질문마다 검색해서 답하는 단계"로 나뉘기 때문에, 파일도 그 둘로 쪼갰다. 질문할 때마다 블로그 전체를 다시 임베딩하면 낭비니까.
기술 스택은 이렇게 갔다.
- LangChain — RAG 파이프라인의 뼈대
- Google Gemini — 임베딩 + 답변 생성 (무료 한도로 충분했다)
- Chroma — 로컬 벡터 DB
- feedparser / BeautifulSoup — 블로그 글 수집·정리
LangChain은 이 프로젝트에서 무슨 일을 할까
LangChain: Observe, Evaluate, and Deploy Reliable AI Agents
LangChain provides the engineering platform and open source frameworks developers use to build, test, and deploy reliable AI agents.
www.langchain.com
이 챗봇의 뼈대는 전부 LangChain으로 짰다. LangChain을 한마디로 하면, LLM 앱을 만들 때 필요한 기능들(문서 로딩, 청킹, 임베딩, 벡터 검색, 프롬프트, LLM 호출)을 표준화된 형태로 제공하고, 그 기능들을 하나의 파이프라인으로 엮어주는 프레임워크다. 각 기능을 직접 밑바닥부터 구현하지 않고, 정해진 인터페이스에 맞춰 조립만 하면 된다는 게 핵심이다.
이 프로젝트의 각 단계가 실제로 어떤 LangChain 기능에 대응하는지 보면 이렇다.
- 문서 표현 — 블로그 글 하나를 Document 객체로 담는다. 본문은 page_content, 제목·링크 같은 부가정보는 metadata에 넣는다.
- 청킹 — RecursiveCharacterTextSplitter로 긴 글을 일정 크기로 쪼갠다.
- 임베딩 — GoogleGenerativeAIEmbeddings로 각 청크를 벡터로 바꾼다.
- 벡터 저장·검색 — Chroma 연동으로 벡터를 저장하고, as_retriever()로 질문과 가까운 청크를 검색한다.
- 프롬프트 — ChatPromptTemplate로 "이 근거로만 답하라"는 지시와 검색 결과를 하나의 프롬프트로 구성한다.
- LLM 호출 — ChatGoogleGenerativeAI로 Gemini에 답변 생성을 요청한다.
- 체인 — 이 부품들을 prompt | llm 처럼 파이프(|)로 연결한다. LangChain의 표현식 문법(LCEL)인데, 앞 부품의 출력이 뒤 부품의 입력으로 그대로 흘러가는 구조라 리눅스 파이프와 비슷하다.
즉 "블로그 글 수집 → 청킹 → 임베딩 → 검색 → 생성"이라는 RAG의 전 과정을, LangChain이 제공하는 표준 기능을 이어붙여 구현한 것이다.
여기서 LangChain을 쓴 이점을 직접 체감한 순간이 있었다. 처음엔 OpenAI로 짰다가 비용 때문에 무료인 Gemini로 갈아탔는데, 바꾼 곳은 딱 세 군데뿐이었다. 임베딩 클래스(OpenAIEmbeddings → GoogleGenerativeAIEmbeddings), LLM 클래스(ChatOpenAI → ChatGoogleGenerativeAI), 그리고 패키지 이름. 검색, 청킹·프롬프트 체인 로직은 한 줄도 안 건드렸다. 각 기능이 표준 인터페이스로 추상화돼 있으니, 모델 공급자를 통째로 바꾸는 일이 클래스 이름 교체로 끝난 것이다.
1. 블로그 글 가져오기 — 티스토리 API가 없어졌다
처음엔 당연히 티스토리 API로 글을 긁으려고 했다. 그런데 찾아보니 티스토리 Open API는 2024년에 종료됐다. 글 목록을 받아오던 엔드포인트가 통째로 사라진 것이다. 대안은 RSS였다. 티스토리는 블로그주소/rss로 최근 글을 XML로 내려준다. API처럼 인증도 필요 없다. 대신 최근 글 일부(최대 50개 정도)만 준다는 한계가 있는데, 우선 파이프라인을 끝까지 돌려보는 게 목적이라 이걸로 시작했다. (전체 글은 나중에 크롤러로 확장할 계획이다.)
RSS를 파싱해서 글 하나를 LangChain의 Document 하나로 만드는 부분이다.
import feedparser
from bs4 import BeautifulSoup
from langchain_core.documents import Document
def fetch_posts(rss_url):
feed = feedparser.parse(rss_url)
docs = []
for entry in feed.entries:
raw_html = entry.content[0].value if getattr(entry, "content", None) else entry.summary
text = BeautifulSoup(raw_html, "html.parser").get_text("\n", strip=True)
if not text:
continue
docs.append(Document(
page_content=text,
metadata={"title": entry.title, "source": entry.link}, # 출처 표시용
))
return docs
여기서 포인트는 metadata에 글 제목과 링크를 같이 저장해두는 것이다. 나중에 답변에 "이 글을 근거로 했다"는 출처를 붙일 때 이 값을 쓴다.
2. 색인 만들기 — ingest.py
가져온 글을 벡터로 만들어 저장하는 단계다. 흐름은 청킹 → 임베딩 → 저장으로 진행된다.
청킹(chunking) 은 긴 글을 적당한 크기로 쪼개는 작업이다. 글이 너무 길면 검색 정확도가 떨어져서, 800자 단위(겹침 100자)로 잘랐다. 겹침을 두는 건 문장이 경계에서 잘려 문맥이 끊기는 걸 완화하기 위해서다.
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_google_genai import GoogleGenerativeAIEmbeddings
from langchain_chroma import Chroma
splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=100)
chunks = splitter.split_documents(docs)
embeddings = GoogleGenerativeAIEmbeddings(model="models/gemini-embedding-001")
Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db",
)
임베딩 은 각 청크를 의미를 담은 숫자 배열(벡터)로 바꾸는 것이다. 의미가 비슷한 글끼리 벡터도 가까워지는 게 핵심이다. 이렇게 만든 벡터를 로컬 벡터 DB인 Chroma에 넣으면 chroma_db/ 폴더가 생기고, 이후엔 이걸 재사용한다.
내 경우 글 10개가 청크 65개로 쪼개졌다. 이 단계는 글을 새로 추가했을 때만 다시 돌리면 된다.
3. 질문에 답하기 — query.py
여기가 일반 챗봇과 갈리는 진짜 RAG 지점이다. 질문이 들어오면 두 단계로 동작한다.
- 검색(Retrieval): 질문을 같은 방식으로 임베딩해서, Chroma에서 의미가 가까운 청크 몇 개를 찾아온다.
- 생성(Generation): 그 청크들을 근거로 붙여서, LLM에게 "이 내용으로만 답해"라고 시킨다.
from langchain_google_genai import ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
embeddings = GoogleGenerativeAIEmbeddings(model="models/gemini-embedding-001")
vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash", temperature=0)
prompt = ChatPromptTemplate.from_messages([
("system",
"너는 사용자의 기술 블로그 내용만 근거로 답하는 어시스턴트다. "
"아래 <context>에 있는 내용으로만 한국어로 답하라. "
"context에 없는 내용은 지어내지 말고 '블로그에서 관련 내용을 찾지 못했어요'라고 답하라.\n\n"
"<context>\n{context}\n</context>"),
("human", "{question}"),
])
docs = retriever.invoke(question) # 1) 검색
context = "\n\n".join(d.page_content for d in docs)
answer = (prompt | llm).invoke({"context": context, "question": question}) # 2) 생성
프롬프트에서 "context에 없으면 지어내지 말라"고 못 박은 게 핵심이다. 이 한 줄 덕분에, 블로그에 없는 걸 물으면 억지로 답하는 대신 "찾지 못했어요"라고 솔직하게 답한다. 그리고 답변 밑에 검색된 글의 링크를 출처로 같이 찍어주면, 어떤 글을 근거로 했는지 눈으로 확인할 수 있다.
4. 오류 났던 부분
① 파이썬 설치가 안 된다
파이썬을 새로 깔려는데 Setup failed 0x80070643이 떴다. 알고 보니 예전에 깔았던 파이썬이 절반만 지워진 채 남아 있어서, 새 설치도 삭제도 다 막고 있었다. 설치 로그를 열어보니 PATH를 등록하는 작은 MSI 조각 하나가 손상돼서 그 지점에서 죽고 있었다. 해결은 두 가지 중 하나다.
- Microsoft 공식 "Program Install and Uninstall Troubleshooter"로 손상된 파이썬을 강제 제거한 뒤 재설치하는 법이 있는데 이건 윈도우 10까지만 제공 되는 것 같다.
- 아니면 아예 Microsoft Store에서 파이썬 설치 (MSI 방식을 안 거쳐서 이 문제를 우회한다)
그리고 설치할 때 "Add python.exe to PATH" 체크는 반드시 켜야 한다. 이거 놓치면 터미널이 python을 못 찾는다.
② pip이 인식이 안 된다
python은 되는데 pip만 "인식되지 않습니다"가 떴다. 이건 pip 대신 python -m pip으로 부르면 바로 해결된다. 같은 pip을 파이썬을 통해 실행하는 것뿐이라 결과는 같다.
python -m pip install -r requirements.txt
③ 임베딩 모델 404
색인 마지막 단계에서 이런 에러가 났다.
404 NOT_FOUND. models/text-embedding-004 is not found for API version v1beta,
or is not supported for embedContent.
내가 쓴 임베딩 모델 이름이 지금 API에서 안 먹히는 거였다. 이럴 땐 내 키에서 실제로 쓸 수 있는 모델 목록을 직접 확인하는 게 확실하다.
from google import genai
client = genai.Client(api_key="YOUR_KEY")
for m in client.models.list():
if "embedContent" in getattr(m, "supported_actions", []):
print(m.name)
이걸 돌려서 나온 임베딩 모델명(models/gemini-embedding-001)으로 바꾸니 바로 됐다. 모델 이름은 시기마다 바뀌니, 404가 나면 이 방법으로 현재 이름을 확인하는 게 제일 빠르다.
5. 돌려본 결과
색인을 마치고 query.py를 실행한 뒤, 예전에 블로그에 썼던 주제를 물어봤다. 그 글의 내용을 요약해서 답하고, 밑에 해당 글 링크까지 출처로 찍혔다. 반대로 블로그에 없는 걸 물으니 "블로그에서 관련 내용을 찾지 못했어요"라고 답했다. 의도한 대로 아는 척 없이 내 글 근거로만 동작한 것이다.


6. 마치며
이론으로만 알던 RAG를 직접 만들어보니 흐름이 이해가 되었다.
다음으로 해보고 싶은 것들은 이렇다.
- RSS 대신 크롤러로 블로그 글 전체(200개)를 색인
- LangGraph로 질문을 여러 단계로 처리하는 에이전트로 확장
일단 최소 버전이 도는 걸 확인했으니, 여기서부터 하나씩 붙여볼 생각이다.
https://github.com/wonee1/BlogChatBot
GitHub - wonee1/BlogChatBot: Langchain을 이용한 블로그 챗봇
Langchain을 이용한 블로그 챗봇 . Contribute to wonee1/BlogChatBot development by creating an account on GitHub.
github.com
LangChain을 사용하여 챗봇 만들기 🦜️🔗
LangChain의 기능 중 Model I/O를 중심으로
velog.io