블로그에 AI 챗봇 붙이기 — 이틀치 회고
어느 순간 블로그에 “나 대신 답해주는 챗봇이 하나 있으면 좋겠다”는 생각이 들었습니다.
거창한 에이전트가 아니라 RAG만 사용해서 만들게 된 단순한 나만의 작고 소중한 블로그 도우미. 이틀 만에 뚝딱 만들어본 과정을 러프하게 정리해봅니다!
AI 챗봇의 초간단 플로우
구조는 생각보다 단순합니다. 블로그 쪽 책임은 두 가지뿐이에요 — 빌드 타임에 포스트 요약·FAQ·추천 질문을 JSON으로 미리 만들어두고, 포스트 페이지를 열면 그 요약을 HTML에 주입해두는 것. 실제 대화 로직(캐시 판단·검색·LLM 호출)은 전부 별도 저장소의 챗봇 API가 담당합니다.
처음엔 그냥 “사용자 질문을 LLM에 그대로 던지면 되지 않나” 수준이었습니다. 포스트 본문을 컨텍스트로 넘기고, LLM이 적당히 자유롭게 대답하게 하려고 했습니다.
그런데 조금 생각해보니 챗봇을 다는 목적 자체가 이 포스트에 대한 해석·질문을 받기 위한 것이더라고요. 이런 맥락에서 LLM에 그냥 다 맡겨버리면 포스트와 관계없는 할루시네이션이 나올까 봐 걱정됐습니다. 그래서 방향을 틀었어요 — 포스트에 대한 정보는 내가 줄 수 있는 만큼 미리 다 정리해두고, LLM은 그 위에서 꼬리질문 정도만 자연스럽게 처리하게 하자.
캐시 레이어를 먼저 두기 — “이거 매번 LLM 부를 일인가?”
처음엔 그냥 단순하게 검색 + LLM 한 줄짜리 흐름으로 시작했습니다. 그런데 직접 챗봇을 열어서 이것저것 물어보다 보니까 두 가지 정도가 자꾸 거슬렸습니다.
- 응답이 느림 — 첫 글자가 나오기까지 몇 초씩 걸리니까 답답한 느낌이 들었어요. 한국인은 빨리빨리 민족!
- 토큰이 훅훅 나감 — 답이 거의 정해진 질문에도 매번 LLM 호출이 들어가니 낭비가 느껴졌습니다
그러다 “이 포스트 어떤 내용이에요?” 같은 질문을 몇 번 던지면서 깨달았어요. 답이 거의 정해져 있는 질문인데도 매번 검색 돌리고 LLM 호출하고 있다는 게 낭비라는 것을 😮💨
그래서 포스트별로 자주 나올 답을 미리 정리해두는 쪽 으로 결정하여, 포스트마다 요약과 예상 Q&A를 빌드 타임에 미리 만들어두고, 챗봇은 질문이 들어오면 일단 이 캐시를 한 번 훑어본 다음 매칭되면 바로 응답하는 식으로 진행하기로 했습니다.
결과적으로는 이 정도면 충분한 거 같더라고요! 포스트 관련 기본 질문의 상당수가 캐시 단계에서 끝나서 응답이 거의 즉시 도착하고, LLM 호출 자체가 안 일어나니까 비용도 안 들고 꽤 그럴싸한 챗봇이 만들어졌습니다 👍
검색은 처음부터 Hybrid로 — “RAG Playground 덕분”
캐시에서 못 잡은 질문은 검색 단계로 넘어갑니다. 챗봇은 어차피 포스트 안에만 떠 있으니까, 검색 대상도 “지금 이 포스트의 본문 청크” 로 좁혀져 있어요. 긴 글이면 질문과 관련된 섹션 몇 개만 골라 LLM 컨텍스트로 묶어주는 게 핵심입니다.토큰도 아끼고 할루시네이션도 줄이고 Qdrant 쪽 쿼리에도 slug 필터를 걸어서 현재 포스트 청크만 후보로 삼도록 고정해뒀어요.
당연히 처음부터 하이브리드 검색
검색 방식은 처음부터 Hybrid Search로 갔습니다. 의미 검색만 쓰면 모델명·버전 번호·코드 식별자처럼 토큰 자체가 의미를 가지는 경우에 약하다는 걸 RAG Playground 시리즈에서 이미 체감해봤거든요 🫣
가령 “text-embedding-3-small 어떻게 설정했어?” 같은 질문이 들어왔을 때, 의미 검색만으론 “임베딩이란 무엇인가” 같은 인접 서술 섹션이 상위에 올라오기 쉽고 정작 설정값이 있는 코드 청크는 밀립니다. 반면 BM25 키워드 검색은 이런 리터럴 토큰을 바로 잡아내주기 때문에 자연어 부분은 의미 검색이, 리터럴 매칭은 키워드 검색이 — 두 축이 서로 보완하는 구조입니다.
구현은 Qdrant에 dense·sparse 벡터를 같이 넣고 fusion: "rrf"로 한 번에 호출하는 식입니다. prefetch limit을 최종 타겟 개수의 4배 정도로 넉넉하게 가져가서 재정렬 여지를 두는 쪽으로 정리했습니다. RAG Playground에서 미리 굴려본 덕분에 별다른 튜닝 없이 한 번에 만족스러운 결과로 안착했죠 👍
실제로 Qdrant에 보내는 쿼리 바디는 이 정도로 단순합니다 — 두 검색을 prefetch로 쥐어주고 rrf로 합친 뒤, slug 필터를 top-level에 걸어서 두 prefetch가 모두 현재 포스트 청크 안에서만 실행되도록 한 구성이에요.
Qdrant는 top-level filter를 prefetch 단계부터 적용하기 때문에 “다 긁고 나서 거르기”가 아니라 “처음부터 슬러그 서브셋 안에서 검색”입니다.
// Qdrant points/query body (요약)
const body = {
prefetch: [
{ query: denseVec, using: 'dense', limit: limit * 4 },
{ query: sparseVec, using: 'sparse', limit: limit * 4 },
],
query: { fusion: 'rrf' },
limit,
filter: {
must: [{ key: 'slug', match: { value: slug } }],
},
}
청킹은 별개로 약간의 시행착오가 있었어요. 처음엔 포스트를 통째로 한 덩어리로 인덱싱했는데 너무 큰 단위라 어떤 부분이 매칭됐는지 모호했고, 반대로 고정 길이로 잘게 쪼개봤더니 맥락이 사라져서 답변이 산만해졌습니다.
이번에도 얼마 전 경험을 살려서
마침 얼마 전에 리뷰했던 RAGPlay 에서 Fixed · Recursive · Parent-Child 청킹을 직접 비교해본 경험이 있었기 때문에 포스트 자체가 Markdown 으로 구조가 잡혀있으니 그 구조를 그대로 따라가는 Recursive 방식이 가장 자연스러워 보였습니다.
실제 구현은 간단해요 — ## 기준으로 섹션 단위 split, 첫 H2 이전은 “도입부”로 묶고, 40자 미만의 빈약한 조각은 버리고헤딩만 있고 본문이 거의 없는 섹션을 노이즈로 간주, 각 청크 앞에 {포스트 제목} — {섹션 제목} 헤더를 붙여서 맥락을 같이 실어보내는 식입니다.
그리고 포스트가 수정될 때마다 전체를 다시 인덱싱하면 시간이 너무 오래 걸려서, 청크별 content hash를 Qdrant payload에 같이 저장해두고 해시가 바뀐 청크만 선택적으로 upsert하도록 했습니다. 섹션이 삭제되면 “현재 존재하지 않는 청크 ID”를 감지해서 같이 지워집니다. 수정 후 재임베딩이 거의 즉시 끝나더라고요 👌
비로그인 환경에서 챗봇 운영하기 — “누가 무한히 부르면 어떡하지?”
막상 챗봇을 만들고 나니까 갑자기 운영 측면 고민이 한꺼번에 몰려왔습니다… 이 블로그는 로그인이 없거든요. 누구든 페이지를 열면 챗봇을 쓸 수 있는 구조예요…
그럴 일은 잘 없겠지만 누군가 봇을 돌려서 챗봇 API를 분당 수백 번 호출하면 무료 티어 한도가 한 시간 안에 소진될 수 있고, 그러면 나는 다음 날 아침에 결제 알림으로 깨는 시나리오 💀
그래서 사용자별 일일 한도 제한을 가장 먼저 박았습니다. 익명 사용자라도 어떤 식으로든 식별해서 “한 사람이 하루에 몇 번까지” 같은 제약을 두어야겠다고 생각했습니다. 정확한 식별 방식은 여기선 적지 않겠지만쉿!, 결과적으로 한 사람이 하루에 던질 수 있는 질문 수에 상한선이 생겼습니다.
여기서 두 번째 고민
“한도를 사용자에게 어떻게 알려줄까” 입니다. 처음엔 그냥 한도가 차면 에러만 띄우는 식이었거든요. 근데 사용자 입장에선 “왜 갑자기 안 되지?” 같은 혼란만 남겠죠?
그래서 입력창 옆에 남은 질문 횟수를 작게 표시하기로 했습니다. 답변이 도착할 때마다 응답에 함께 들어있는 한도 정보를 받아서 화면에 반영하는 식으로 적용했습니다. 줄어드는 게 보이니까 사용자도 “아, 슬슬 아껴 써야겠다” 같은 감을 잡을 수 있고요. 아껴 쓰세요 ^^7
하나 더
세 번째는 한도가 차버린 뒤의 UX. 처음엔 입력창만 비활성화했는데 너무 매정한 느낌이었어요. 그래서 추천 질문 칩도 같이 숨기고, “내일 다시 가능해요” 같은 메시지를 부드럽게 띄우는 쪽으로 정리했습니다. 일종의 잠금 상태를 명확하게 보여주도록 했습니다.
대화 기록은 로컬에만
그 다음은 대화 기록이었어요. 로그인이 없으니 서버에 저장할 사용자 키가 없고, 그렇다고 매번 새로 시작하면 답답하니까 결국 로컬 스토리지에 저장하기로 했습니다. 같은 페이지에 다시 들어오면 이전 대화가 그대로 이어서 보이고, 다른 디바이스에서는 새로 시작하는 식. 로그인이 없는 만큼 대화도 그 디바이스에만 머무는 게 자연스럽기도 하고요 ✌️
답변 품질을 끌어올리기 — “왜 이 글을 묻는데 엉뚱한 답이 오지?”
캐시·RAG까지 붙이고 나서 마지막으로 다듬은 건 “LLM에게 주는 기본 배경” 이었습니다. RAG 청크는 질문마다 동적으로 바뀌는 데이터지만, 그 청크가 어떤 글의 어느 맥락에 있는 조각인지는 별도로 알려줘야 답변이 안정적이더라고요.
처음엔 URL 하나만 넘기는 식으로 시작했는데, LLM이 “이게 어떤 글인지”를 URL 문자열로만 유추해야 해서 답변이 흔들렸습니다. 그래서 백엔드가 슬러그로 KV에서 포스트 컨텍스트를 로드해 LLM 프롬프트에 같이 실어주는 쪽으로 적용해 봤습니다 — 글 제목, 소개, summary 정도를 묶어서 주입! RAG 청크는 “질문에 딱 맞는 구체 본문”, 메타는 “이 글 전체 배경” — 두 축이 같이 들어가니까 답변 톤이 확실히 자리 잡는 것을 확인할 수 있었습니다 😊
이 흐름의 연장선에서 포스트 내 이미지에도 별도 설명 필드를 하나 더 심어뒀습니다. 사람용 캡션과 별개로, 이미지가 무엇을 보여주는지 텍스트로 풀어놓는 칸이에요. 현재는 이 설명이 LLM 프롬프트까지 전달되진 않지만, 이미지 많은 글에서 답변 맥락을 더 단단하게 만들 여지를 남겨둔 셈입니다. 사람은 그림을 보고 이해하지만 챗봇은 텍스트로만 알 수 있기 때문입니다.
뭘 좋아할지 몰라서 미리 준비
챗봇을 처음 열었을 때 입력창이 비어 있으면 의외로 막막하지 않나요? 무엇이든 물어볼 수 있다는 건 역설적으로 무엇을 물어야 할지 모르겠다는 뜻이기도 하다는 거죠.
그래서 포스트마다 미리 준비해둔 추천 질문 몇 개를 클릭 가능한 칩으로 띄워두기로 했습니다. 클릭 한 번이면 질문이 자동으로 입력되고 답변이 돌아오는 식. 답변 후에는 다음 칩 몇 개를 또 띄워서 대화가 자연스럽게 이어지도록 만들었어요!
👍 / 👎 로 캐시 키우기
추천 질문 칩까지 준비해두고 나서 붙인 마지막 장치가 답변 품질의 피드백 루프입니다. 답변에 좋아요/싫어요 버튼을 달았는데, 어떤 답변이 만족스러웠는지를 사용자가 직접 알려주면 그 데이터를 모아서 어떤 Q&A를 캐시에 추가할지 결정하는 데 쓸 수 있거든요. 캐시가 풍부해지면 다음 사용자는 같은 질문에 즉시 응답을 받게 되고요. 이 루프를 만들어두니까 챗봇이 시간이 갈수록 점점 똑똑해지는 구조가 됩니다 👍
삽질 모음
iOS 입력창 자동 확대: 모바일에서 챗봇을 열고 입력창을 탭했더니 화면 전체가 줌인되는 문제가 있었습니다. iOS 기준으로 모바일 사파리는 글자 크기가 일정 값 미만인 입력창을 포커스 시 자동으로 확대하는 동작이 있기 때문에 입력창 글자 크기를 충분히 키워서 해결. 너무 유명한 버그인데 매번 까먹습니다ㅎㅎ…
페이지 전환 + 다크모드: 블로그 안에서 다른 글로 이동했더니 화면이 갑자기 라이트모드로 풀려있었습니다. 새로고침하면 다시 다크가 적용되고요. 부드러운 페이지 전환 기능을 켜두면 페이지 이동 시 루트 클래스가 초기화되면서 테마가 풀려버린다는 걸 뒤늦게 알았습니다. 페이지 로드 이벤트에서 저장된 테마를 다시 적용해주는 코드를 추가해야 했습니다.
그 외 기억나는 것들:
- 일일 한도의 날짜 무효화 — 다음 날 챗봇을 열어보니 “어제 남은 횟수”가 그대로 표시되고 있었습니다. 한도 저장할 때 날짜를 같이 두고 날짜가 바뀌면 무시하도록.
- 떠다니는 미리보기 칩 닫기 — 처음 방문 시 안내용으로 잠깐 떠야 하는 칩이 새 페이지마다 계속 다시 뜨는 게 거슬렸어요. 한 번 닫으면 세션 동안 안 뜨도록 작은 플래그 하나
마무리
이틀 만에 욕심이 좀 많았나 싶기도 한데, 결과적으로는 꽤 만족스럽게 동작합니다 👍
처음부터 “캐시 + 검색·LLM”으로 그려놓고 시작한 게 아니라, 직접 써보면서 거슬리는 지점에 하나씩 레이어를 끼워 넣다 보니 꽤 괜찮은 AI 챗봇이 완성되었습니다.
물론 아쉬운 점도 아직 많습니다. 새 포스트가 인덱싱에 자동으로 반영되도록 하는 자동화는 아직 수동이고, 언제 어떤 트리거로 돌릴지는 더 고민이 필요합니다 🤔
API 본체에 대한 이야기는 따로 풀 자리가 있으면 좋겠다고 생각하지만, 이번 글에서는 일부러 경계선 안쪽은 비워뒀습니다. 블로그 쪽에서 보이는 결정과 흐름만 정리하는 회고로 마무리하는 게 깔끔할 것 같아서요.
아무튼 앞으로 챗봇 계속해서 발전해 나가는 모습 보여드리겠습니다. 너무 많이는 말고 조금씩 적당히 잘 이용해주시면 감사하겠습니다 ^^7