내 AI 챗봇이 계속해서 무언가를 잊어버렸던 이유 (그리고 해결 방법)
요약
단순한 청킹 방식의 한계를 극복하기 위해 계층적 요약을 활용한 재귀적 검색(Recursive Retrieval) 기법을 소개합니다. 문서의 섹션별 요약을 먼저 검색하여 문맥을 파악한 뒤 세부 청크를 찾는 2단계 접근법을 통해 RAG 성능을 개선할 수 있습니다.
핵심 포인트
- 고정 크기 청킹은 분산된 문맥을 연결하는 데 한계가 있음
- 계층적 요약을 통해 문맥 계층 구조를 구축하는 것이 핵심
- 요약본 검색 후 세부 청크를 찾는 2단계 검색 방식 제안
- 프롬프트 크기를 효율적으로 관리하며 답변 정확도 향상 가능
지난 2주간의 주말을 제 사이드 프로젝트를 위한 고객 지원 챗봇을 만드는 데 보냈습니다. 이 챗봇은 우리의 문서(documentation)를 바탕으로 질문에 답변하도록 설계되었습니다. 첫날은 마법 같았습니다. 간단한 질문에는 완벽하게 답변했으니까요. 하지만 어려운 질문들이 등장하기 시작했습니다.
A 사용자가 "작년에 쓰던 방식이 작동하지 않는데, 복구 이메일 옵션을 사용하여 비밀번호를 어떻게 재설정하나요?"라고 물었습니다. 챗봇은 비밀번호 재설정 페이지로 연결되는 일반적인 링크만을 답변으로 내놓았습니다. 완전히 쓸모없는 답변이었죠. 문제는 언어 모델 (language model)이 아니라, 관련 문맥 (context)이 세 개의 서로 다른 문서에 흩어져 있었고, 저의 단순한 검색 (retrieval) 설정으로는 그 점들을 연결할 수 없었다는 것이었습니다.
실패한 단순한 접근 방식
저의 첫 번째 시도는 간단했습니다. 모든 문서를 고정된 크기의 청크 (chunks, 512 tokens)로 나누고, OpenAI embeddings를 사용하여 임베딩한 뒤, 상위 3개의 청크를 프롬프트 (prompt)에 집어넣는 것이었습니다. 이는 짧고 고립된 답변에는 잘 작동합니다. 하지만 사용자가 이전 문맥을 참조하는 다단계 질문("작년에 쓰던 그 방식")을 던질 때, 고정된 청크들은 필요한 배경 지식이 부족한 경우가 많았습니다.
슬라이딩 윈도우 (sliding window) 방식, 즉 50%가 겹치는 중첩 청크 (overlapping chunks) 방식을 시도해 보았습니다. 그것이 약간의 도움이 되긴 했지만, 관련 데이터가 서로 다른 섹션에 위치할 때는 여전히 정보를 놓치곤 했습니다. 더 나쁜 점은 대화 기록이 길어질수록 프롬프트의 크기가 급격히 커진다는 것이었습니다. 챗봇이 다음 질문에 "모르겠습니다"라고 말하는 것을 방지하기 위해 저는 수천 개의 토큰 (tokens) 비용을 지불하고 있었습니다.
실제로 효과가 있었던 방법: 계층적 요약을 활용한 재귀적 검색 (recursive retrieval with hierarchical summarization)
돌파구는 제가 '청킹 (chucking)'에 대해 생각하는 것을 멈추고 '문맥 계층 구조 (context hierarchy) 구축'에 대해 생각하기 시작했을 때 찾아왔습니다. 아이디어는 다음과 같습니다:
- 문서를 거친 섹션(coarse sections)으로 분할합니다 (H2 헤더 또는 논리적 구분 기준).
- 각 섹션에 대해 짧은 요약(summary)을 생성합니다 (GPT-3.5-turbo-small과 같은 더 저렴한 LLM 사용).
- 각 섹션의 요약본과 전체 텍스트를 모두 임베딩 (Embed) 합니다.
- 쿼리(query) 시, 먼저 상위 K개의 요약본을 검색 (retrieve) 합니다. 이를 사용하여 어떤 전체 섹션을 가져올지 결정합니다.
- 그 다음, 선택된 섹션 내부에서 두 번째 검색 (second retrieval)을 수행하여 정확한 청크 (chunks)를 찾습니다.
이 2단계 접근 방식 (two-stage approach) 덕분에 프롬프트 (prompt)의 크기를 폭발적으로 키우지 않고도 문서의 서로 다른 부분에서 정보를 요구하는 질문들을 처리할 수 있었습니다. 요약본은 목차 (table of contents) 역할을 하여, LLM이 문맥 (context)을 확정하기 전에 어디를 살펴봐야 할지 알 수 있게 해줍니다.
코드 (단순화되었으나 실제 적용 가능한 형태)
다음은 계층적 검색 (hierarchical retrieval)을 수행하는 Python 코드 스니펫입니다. 오케스트레이션 (orchestration)을 위해 LangChain을 사용했지만, 이 패턴은 도구에 구애받지 않습니다 (tool-agnostic).
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import FAISS
...
이는 최소한의 예시입니다. 실제 운영 환경 (production)에서는 벡터 (vectors)를 캐싱 (cache)하고 청크 인덱스 (chunk indices)를 적절히 처리해야 합니다. 핵심적인 교훈은 2단계 아이디어입니다.
배운 점 (Lessons learned)
- 요약본은 저렴한 보험입니다. 요약본 생성 비용은 문서당 약 0.02달러 정도였는데, 이는 나중에 발생할 막대한 토큰 (token) 낭비를 막아주는 아주 작은 선행 비용이었습니다.
- 청크 크기 (Chunk size)는 트레이드오프 (trade-off) 관계입니다. 저는 거친 입도 (coarse granularity)를 위해 2,000단어 섹션을 선택했고, 정밀한 검색 (fine retrieval)을 위해 500단어 청크를 선택했습니다. 결과는 문서 구조에 따라 달라질 수 있습니다.
- 이 패턴이 모든 상황에 완벽한 것은 아닙니다. 지식 베이스 (knowledge base)가 단일 긴 기사(예: 소설)라면 계층적 검색은 큰 도움이 되지 않습니다. 이 경우 주제 추적 (topic tracking)을 포함한 슬라이딩 윈도우 (sliding window) 방식이 필요할 수 있습니다.
- 대안도 존재합니다. 문서에 대해 작은 모델을 미세 조정 (fine-tune)하거나, Cohere와 같은 리랭커 (reranker)를 사용할 수도 있습니다. 하지만 계층적 검색이 유지보수하기에는 더 간단합니다.
이 방식을 피해야 할 때
만약 사용자의 질의가 항상 단일 사실(
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기