-
RAG의 배경과 make_retriever — LLM이 모르는 문서를 검색하는 방법IT 2026. 6. 28. 22:00
1. 배경 — LLM이 모르는 것들
LLM은 학습 시점에 공개된 데이터만 알고 있다. 인터넷에 공개된 텍스트, 코드, 논문이 재료다. 반면 영화 정보 자료, 사내 위키, 어제 올라온 뉴스, 그리고 내가 작성한 문서는 학습에 포함되지 않는다. "올해 개봉한 '그 영화'의 OST를 누가 작곡했어?"를 물으면 LLM은 그냥 모른다.
이 문제를 해결하는 방법으로 파인튜닝(fine-tuning)이 먼저 떠오른다. 우리 문서를 데이터셋으로 만들어 LLM에 추가 학습시키는 것이다. 하지만 파인튜닝은 비용이 크고, 문서가 업데이트될 때마다 재학습이 필요하다는 결정적인 문제가 있다. 영화 정보 한 줄이 바뀔 때마다 모델을 다시 굽는 건 현실적이지 않다.
RAG(Retrieval-Augmented Generation)는 다른 방향으로 접근한다. LLM을 바꾸는 대신, 질문이 들어오면 관련 문서를 먼저 검색해서 프롬프트에 끼워 넣는다. 비유하자면 LLM은 도서관의 사서(司書)이고, RAG는 사서가 답변하기 전에 관련 책을 먼저 책상 위에 꺼내다 주는 도우미다. 사서는 책을 외울 필요가 없다 — 눈앞에 펼쳐진 책을 읽고 답하면 된다.
2. 문제 — 문서를 통째로 넣을 수 없다
"그럼 그냥 문서 전체를 프롬프트에 붙이면 되지 않나?"라는 생각이 든다. 하지만 영화 정보 자료는 수백 페이지에 달한다. 이를 통째로 넣으면 두 가지 문제가 생긴다.
- 컨텍스트 한계 초과: LLM이 한 번에 처리할 수 있는 토큰 수는 제한되어 있다. 수백 페이지는 한계를 넘는다.
- 비용 폭증: API 요금은 입력 토큰 수에 비례한다. 모든 질문에 전체 영화 정보 자료를 붙이면 청구서가 감당할 수 없는 수준이 된다.
해결책은 문서를 작은 청크(chunk)로 쪼개고, 질문과 가장 관련 있는 청크만 골라서 프롬프트에 넣는 것이다. "관련 있는 청크를 고른다"는 것이 바로 검색(retrieval)이고, 이를 구현한 것이 리트리버(retriever)다.
3. 파인튜닝 방식 vs RAG 방식
두 접근법의 구조를 비교해보자. 먼저 파인튜닝 방식이다.
파인튜닝 방식은 문서가 모델 가중치에 직접 녹아든다. 문서가 바뀔 때마다 학습 → 배포 사이클 전체를 반복해야 한다. 한 번 구워진 모델은 새 지식을 흡수하지 못한다는 것이 핵심 트레이드오프다.
다음은 RAG 방식이다.
RAG 방식에서는 LLM을 전혀 건드리지 않는다. 문서가 바뀌면 벡터 DB만 다시 인덱싱하면 된다. 모델 재학습 없이 수 분 안에 최신 정보를 반영할 수 있다. "LLM은 추론 엔진, 문서는 외부 저장소"라는 역할 분리가 이 방식의 핵심이다.
4. RAG 전체 흐름
흐름의 핵심은 두 번의 임베딩이다. 사전에 문서를 임베딩해 벡터 DB에 저장해두고, 질문이 들어오면 질문도 같은 방식으로 임베딩해서 벡터 공간에서 거리가 가까운 청크를 찾는다. "거리가 가깝다"는 것은 의미가 유사하다는 뜻이다. 키워드가 아니라 의미 기반으로 검색하기 때문에 "주인공이 죽어요"와 "결말에서 캐릭터가 사망합니다"도 같은 청크를 찾아낼 수 있다. 놓치기 쉬운 함정은 임베딩 모델의 일관성이다. 저장 시와 검색 시 반드시 같은 임베딩 모델을 사용해야 한다. 다른 모델을 쓰면 벡터 공간이 달라져 검색이 완전히 어긋난다.
5. 해결 방법 — make_retriever 파이프라인
위 흐름을 코드로 구현한 것이
make_retriever함수다. 4단계로 구성된다.1단계에서 디렉토리 전체의 텍스트 파일을 읽어 들이고, 2단계에서 작은 조각으로 분할한다. 3단계에서 각 조각을 벡터로 변환해 Chroma에 저장하고, 4단계에서 질문을 받아 유사 청크를 반환하는 리트리버 객체를 돌려준다. 함수 하나가 "인덱싱 파이프라인 전체"를 캡슐화한다는 점이 설계의 핵심이다.
def make_retriever(dir_path, collection_name, chunk_size=1024, chunk_overlap=128, k=3): docs = DirectoryLoader(dir_path, glob="**/*.txt").load() # 1. 로드 chunks = RecursiveCharacterTextSplitter( # 2. 청킹 chunk_size=chunk_size, chunk_overlap=chunk_overlap).split_documents(docs) vector_store = Chroma.from_documents( # 3. 임베딩 + 저장 chunks, embedding=embed_model, collection_name=collection_name) return vector_store.as_retriever(search_kwargs={"k": k}) # 4. 리트리버 반환코드에서 주목할 점은
RecursiveCharacterTextSplitter의 동작 방식이다. 이름 그대로 재귀적으로 분할한다. 먼저 단락(문단) 단위로 나누고, 그래도chunk_size를 초과하면 문장 단위로, 그래도 크면 단어 단위로 내려간다. 임의로 자르지 않고 자연스러운 경계를 찾아 자르기 때문에 청크 품질이 높다. 함정은embed_model이 함수 외부에서 주입된다는 점이다. 이 모델이 무거운 경우 함수 안에서 매번 초기화하면 성능이 급격히 떨어지므로, 반드시 외부에서 한 번만 생성해 전달해야 한다.6. chunk_size와 chunk_overlap — 파라미터가 결과를 바꾼다
위 다이어그램이 보여주는 핵심은 슬라이딩 윈도우 구조다. 청크가 겹치지 않으면 문장이 청크 경계에서 정확히 잘릴 수 있다. "이 영화의 [청크 1 끝]" / "[청크 2 시작] 주인공은 베테랑 형사입니다"처럼 문맥이 끊어진다.
chunk_overlap=128이면 이전 청크의 마지막 128자가 다음 청크 앞에 반복된다. 경계에서 잘린 문장이 어느 한쪽 청크에는 온전히 포함될 가능성이 높아진다. 놓치기 쉬운 함정은chunk_size가 너무 작을 때다. 문맥이 짧은 청크에 분산되면 검색 품질이 오히려 떨어진다. 반대로 너무 크면 관련 없는 내용이 함께 들어가 LLM에 노이즈를 준다. 1024자는 실용적인 시작점이지만, 문서 특성에 따라 반드시 실험이 필요하다.7. 여러 리트리버 인스턴스화 — 컬렉션 분리 전략
같은 함수에 서로 다른 경로와 컬렉션명을 넣어 독립적인 검색 공간을 만든다.
# 영화 장르마다 별도 컬렉션으로 분리 retriever_action = make_retriever("./movie_info/action", "action_docs") retriever_drama = make_retriever("./movie_info/drama", "drama_docs") retriever_scifi = make_retriever("./movie_info/scifi", "scifi_docs")컬렉션 분리의 핵심 이점은 노이즈 제거다. 액션 영화 정보를 묻는 질문에 드라마 영화 정보 청크가 함께 검색되면, LLM이 혼동할 수 있다. 컬렉션을 분리하면 질문의 의도에 맞는 도메인만 검색한다. 단, 이를 위해서는 질문이 어느 카테고리인지를 먼저 분류하는 로직이 앞단에 필요하다. 라우터가 없으면 분리된 리트리버를 어떻게 고를지 결정하지 못한다는 점이 설계 시 놓치기 쉬운 부분이다.
8. 파라미터 요약
파라미터 역할 너무 작으면 너무 크면 chunk_size청크 1개의 최대 크기 문맥 손실, 파편화 노이즈 증가, 비용 상승 chunk_overlap인접 청크 간 겹침 크기 경계에서 문장 절단 중복 청크 증가, 인덱스 비대 k검색 시 반환할 청크 수 정보 부족, 누락 프롬프트 비대, 비용 증가 chunk_overlap은chunk_size의 10~20% 정도가 실용적인 시작점이다.k는 3~5 사이에서 도메인의 문서 밀도에 따라 조정한다. 밀도가 높고 단답형 질문이 많으면 3, 복잡한 절차 문서라면 5~7도 고려한다.9. 정리
RAG는 LLM을 바꾸지 않고 "모르는 문서"를 다루는 가장 실용적인 방법이다. 문서 → 청크 → 임베딩 → 벡터 DB → 검색 → 프롬프트 조립이라는 파이프라인 전체를
make_retriever함수 하나로 캡슐화하면, 도메인이 늘어날 때 함수 호출 한 줄로 새 검색 공간을 추가할 수 있다. 파인튜닝이 "지식을 모델에 굽는" 방식이라면, RAG는 "지식을 외부에 두고 필요할 때 꺼내 쓰는" 방식이다. 문서가 자주 바뀌거나 도메인이 여럿이라면, 파인튜닝보다 RAG가 훨씬 유연하고 유지보수하기 쉽다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
@tool이 내부에서 하는 일 — Pydantic BaseModel이 LLM의 호출 인터페이스가 되는 과정 (0) 2026.06.30 Pydantic BaseModel이란 무엇인가 — 타입 힌트를 진짜 검증으로 바꾸는 도구 (0) 2026.06.29 LangGraph가 Annotated를 쓰는 이유 — 덮어쓰기 문제와 리듀서의 등장 (0) 2026.06.29 RAG 에이전트 완전 조립 — create_agent부터 동작 추적까지 (1) 2026.06.29 검색 결과를 에이전트 도구로 — build_context와 @tool 패턴 (0) 2026.06.28 생성과 검증의 분리 — generator 노드와 validator 노드 설계 (0) 2026.06.28 LangGraph 자기 수정 패턴의 State 설계 — 루프를 위한 5가지 필드 (0) 2026.06.27 LangGraph 조건부 라우팅 — 상태 값으로 다음 노드를 결정하는 방법 (0) 2026.06.27 LLM을 분류기로 쓰기 — SystemMessage와 HumanMessage로 Classifier Node 만들기 (0) 2026.06.27 LangGraph 상태에 메시지 외 필드 추가하기 — RouterState 설계 (0) 2026.06.26