문서 읽는 데 79분 · day14-5

Day 14.5. RAG 입문 — 용어와 개념 한 바퀴

전체 26강 중 16강 · 스프링 AI
난이도 · 심화선수지식자바 기초스프링 부트

ℹ️스프링 부트로 만든 ai-friends 프로젝트 위에 얹어 진행해요. 자바·스프링이 처음이라면 먼저 “서버 만들기” 트랙부터 권해요.

안녕하세요, 여러분의 Spring AI 가이드 홍순구 튜터입니다.

지난 시간 우리는 Agent 2 패턴 (Orchestrator-Workers + Evaluator-Optimizer) 위에 가드 4 부품 (호출 횟수·시간·토큰·툴) 까지 얹었어요.

ARIA 와 HARU 가 한 발화에 동시에 반응하고, 분배 명세를 마스터 ChatClient 가 짜고, 그 위에 4 advisor 가 외→내 순서로 깔리는 흐름까지 익혔습니다.

그런데 그 마지막 부분에서 ARIA 한 명이 한 가지 한계를 드러냈어요. 같이 한 번 더 짚어볼게요.

마스터: "ARIA, 어제 우리가 마지막으로 나눈 약속이 뭐였더라?" ARIA: "음... 제 기억엔 한정된 컨텍스트만 있어서, 어제의 대화 전체를 짚어드리긴 어려워요."

LLM 의 컨텍스트 윈도우 는 한정되어 있고, 어제의 일기 한 줄은 모델 가중치 안에 없어요. ARIA 는 지금 이 대화 안에서 우리가 무슨 말을 했는지는 압니다 — Day 5 ChatMemory 덕분이죠.

하지만 어제 적은 일기 한 줄, 세계관 설정집의 첫 만남 장면, 마스터가 일주일 전에 보낸 사진의 캡션 — 이런 외부 지식 은 모델이 학습할 때 본 적이 없으니 답할 길이 없어요.

다음 시간부터 본격적으로 다룰 도구가 바로 이 부분이에요. RAG (Retrieval-Augmented Generation) — Agent 의 머리에 외부 사전 한 권을 얹는 흐름입니다.

그런데 잠시만요. Day 15 의 첫 Step 을 펼쳐 보면 임베딩 · 코사인 거리 · ANN · HNSW · IVFFlat · VectorStore · 청킹 · TokenTextSplitter 가 한꺼번에 쏟아져 나와요.

이 용어들을 코드와 함께 처음 만나면 학생 입장에선 "이게 RAG 코드인지 용어집인지" 가 흐려져요.

오늘은 한 번 들고 갑니다. Day 15 의 코드로 곧장 들어가기 전에, 들어갈 곳에 미리 빈 슬롯을 만들어두는 한 시간이에요.

코드 변경은 0 — 비유와 도식 위주로 5 개념 (임베딩 · 벡터 DB · R·A·G 분해 · 청킹 · Top-K 검색) 을 익혀요.

마지막 Step 에서 그 5 개념이 Day 15·16 의 어느 코드 부분으로 자라는지 로드맵 한 장을 그립니다.

💡 오늘 수업의 핵심

"코드 한 줄 짜기 전에, 5 개념을 비유로 머리에 담고 — 다음 시간 Day 15 의 임베딩·pgvector·청킹 코드가 빈 슬롯 위로 자연스럽게 떨어지는 한 시간"

🎯 학습 목표

  • 왜 RAG 가 필요한가 — Day 14 ARIA 의 한계 + 외부 지식 주입의 비용·속도·갱신 3 축 가치를 익혀요.
  • 임베딩의 본질 — 문장이 벡터가 되어 의미 공간에서 가까움을 갖는 모습을 비유로 익혀요.
  • 벡터 DB 와 ANN 인덱스 — HNSW vs IVFFlat 의 트레이드오프를 도서관 비유로 풀어요.
  • R·A·G 세 글자 분해 — 검색·증강·생성이 어디서 잘리고 어디서 이어지는지.
  • 청킹과 TokenTextSplitter — 청크 크기·오버랩의 트레이드오프, Before/After 한 묶음으로.
  • Day 15·16 로드맵 — 오늘 익힌 5 개념이 다음 시간 어느 코드 부분으로 자라는지 한눈에.

Step 1. RAG 가 왜 필요한가 — Day 14 ARIA 의 한계와 외부 지식 주입

지난 시간 마지막 ARIA 의 발화로 다시 돌아가 볼게요.

"음... 제 기억엔 한정된 컨텍스트만 있어서, 어제의 대화 전체를 짚어드리긴 어려워요."

이 한 줄에 LLM 운영의 두 가지 본질적 한계가 다 들어 있어요.

첫째 — 컨텍스트 윈도우 가 한정되어 있어요. 2026 년 최신 모델이 1M 토큰까지 받아준다고 해도, 매 호출마다 그만큼 토큰을 흘려보내는 건 비용·속도가 폭발해요.

둘째 — LLM 가중치 안에 외부 지식이 없어요. 모델은 학습 시점까지의 공개 데이터로 만들어진 거예요.

내가 어제 적은 일기, 우리 게임의 세계관 설정집, 캐릭터별 프로필 — 이런 자료는 모델이 본 적이 없으니 답할 수 없습니다.

바리스타와 손님 메모 — 외부 지식 주입의 비유

비유 하나 던져볼게요. 동네 단골 카페의 바리스타를 떠올려 보세요.

바리스타는 커피 만드는 법, 원두의 차이, 추출 방식 — 이런 일반 지식은 머릿속에 다 있어요. 학습된 지식이죠.

그런데 제가 가게에 들어와 "지난번에 마셨던 그 시즌 라떼 다시 한 잔 주세요" 라고 하면? 바리스타가 매일 모든 손님의 주문 이력을 머리에 담아둘 수는 없어요.

그래서 가게에 손님 메모 한 권을 둡니다. 제가 들어오면 그 페이지를 펼쳐 보고 "아, 지난주에 호두 라떼 드셨네" 를 확인한 다음 답해요.

이게 RAG 의 본질이에요.

  • 바리스타의 머릿속 지식 = LLM 가중치 — 일반 지식, 추출 방식, 어휘
  • 손님 메모 = 외부 지식 베이스 (KB, Knowledge Base) — 사용자별 일기, 세계관 설정집, 과거 대화
  • 메모를 펼쳐 보는 동작 = Retrieval (검색)
  • 펼친 페이지를 보며 답하는 동작 = Augmented Generation (증강 생성)

머릿속에만 든 지식 vs 책장 위 사전 — 두 자원을 같이 쓰는 방식이에요.

한쪽은 빠르고 풍부하지만 갱신이 어렵고, 다른 쪽은 갱신이 자유롭지만 매번 펼쳐 봐야 해요. RAG 는 그 둘을 한 흐름으로 잇는 모습입니다.

"그냥 LLM 에 KB 를 통째로 박으면 안 되나요?" — 비용·속도·갱신 3 축

자, 여기서 학생들이 자주 던지는 질문이 있어요.

🙋 학생 질문 — "튜터님, 요즘 모델은 1M 토큰까지 받는다는데, 그냥 컨텍스트 윈도우에 KB 전체를 넣으면 안 되나요?"

좋은 질문이에요. 결론부터 말하면 세 가지 이유 로 그 선택이 깨져요.

비용 — 매 호출마다 KB 전체가 토큰으로 흘러갑니다. 사용자 10 명이 30 번씩만 대화해도 KB 가 300 번 똑같이 송신돼요.

토큰 청구서가 KB 크기 × 호출 횟수로 늘어나요. KB 가 10만 토큰만 돼도 한 달 청구서가 수백만 원 단위로 튀어요.

속도 — 100K 토큰을 매 호출에 넣으면 응답 지연이 수 초씩 늘어납니다. 미연시 게임이 답답해 보이는 첫 번째 신호. 캐릭터가 한 마디 답하는 데 5 초씩 걸리면 몰입이 깨져요.

갱신 — KB 가 자라거나 정정될 때마다 시스템 프롬프트를 다시 빌드해서 모든 ChatClient 를 재기동? 운영에선 안 통하는 방식이에요.

새 일기 한 줄 추가하는 데 앱 재시작이 필요하다면, 그건 운영 시스템이 아니에요.

RAG 의 본질은 질문에 진짜 필요한 청크 N 개만 골라 컨텍스트에 흘리는 모습입니다. 비용·속도·갱신성 세 가지를 한 번에 푸는 흐름이라 2026 년 LLM 운영의 표준이 됐어요.

세 축 비교 표 — 통째 vs RAG

세 축을 한 표로 정리해 둘게요.

컨텍스트 통째 박기 RAG (관련 청크만)
비용 KB 크기 × 호출 횟수 만큼 토큰 청구 질문당 N 개 청크 (보통 3~5 개) 만 토큰 청구
속도 100K+ 토큰 입력 → 응답 수 초 지연 수백 토큰 입력 → 즉답 가능
갱신성 KB 변경 → 앱 재기동 KB 변경 → 벡터 DB 에 한 줄 추가만
확장 한도 모델 컨텍스트 윈도우 사실상 무제한 (수천만 청크까지)

왼쪽은 거대한 KB 박스가 통째로 LLM 의 입력 슬롯에 욱여넣어지는 모습(붉은 경고 아이콘), 오른쪽은 KB 박스에서 작은 청크 3 개만 화살표로 빠져나와 LLM 에 들어가는 모습(녹색 체크).

실무 사례 — RAG 가 들어간 운영 현장들

본 강의의 ai-friends 외에 2026 년 운영 현장에서 RAG 가 쓰이는 곳들을 짧게 짚어 볼게요.

  • 사내 위키 챗봇 — "우리 회사 휴가 정책이 어떻게 되나요?" 에 답하는 부분. 사내 위키 (Notion · Confluence) 가 KB 가 되고, 직원의 질문이 임베딩되어 위키 청크 Top-K 가 LLM 컨텍스트로 흘러요.
  • 법률 어시스턴트 — "이 계약서의 N 조 조항이 한국 상법 X 조와 어떻게 다른가?" 같은 질문. 한국 상법 전문 + 판례 데이터베이스가 KB 가 돼요.
  • 고객 지원 챗봇 — "제품 반품 절차가 어떻게 되나요?" 같은 질문. FAQ + 도움말 페이지 + 과거 상담 이력이 KB.
  • 개발자 도구의 AI 어시스턴트 — "이 함수의 시그니처가 우리 코드베이스 어디서 정의됐나?" 같은 질문. 코드베이스 자체가 KB 가 돼서 코드 청크 Top-K 가 흘러요.

공통점이 보이시죠? 모두 LLM 가중치 안에 없는 도메인 지식을 외부에서 끌어오는 흐름이에요. 우리 ai-friends 의 마스터 일기 · 캐릭터 세계관도 같은 흐름입니다.

KB (Knowledge Base) 란 무엇인가

용어 하나 더 익혀둘게요. RAG 가 검색해 오는 외부 저장소KB (Knowledge Base, 지식 베이스) 라고 불러요.

ai-friends 의 KB 는 이런 자료들로 채워질 거예요.

  • 세계관 설정집 — "이 게임의 시간 배경은 2027 년, 도시는 서울이며..."
  • 캐릭터 프로필 — "ARIA 는 28 살 데이터 분석가, MBTI INTJ, 차분하고 분석적..."
  • 과거 대화 요약 — "지난 주 마스터와 ARIA 가 카페에서 만난 장면에서..."
  • 마스터의 일기 — "오늘 ARIA 에게 좋아한다는 말을 들었다..."

이런 자료들이 마크다운 파일 한 묶음으로 classpath:character-knowledge/ 에 들어가요. Day 15 에서 그 디렉토리가 어떻게 청크로 잘려 pgvector 에 저장되는지 손으로 돌려봅니다.

💡 튜터의 결론

RAG 는 "LLM 의 모자란 지식을 외부 사전으로 보강하는 흐름" 이에요. 비용·속도·갱신성 3 축을 한 번에 풀어서 2026 년 LLM 운영의 표준이 됐어요.

다음 Step 에서 그 사전을 어떻게 만드는지 — 임베딩으로 들어갑니다.


Step 2. 임베딩의 본질 — 문장이 의미 공간의 한 점이 된다

RAG 의 첫 번째 핵심 부품은 임베딩 (embedding) 이에요. 발음 그대로 임-베-딩. 영어로는 embedding — 무언가를 다른 공간에 끼워 넣는다는 뜻입니다.

뭘 어디에? 문장을 의미 공간 (semantic space) 에 끼워 넣어요.

첫 비유 — 지도 위 도시들

임베딩을 처음 만날 때 가장 쉬운 비유가 지도예요.

왼쪽 평면에 한국 지도를 띄우고 서울·인천·부산·제주가 점으로 표시된 모습. 옆에는 같은 평면에 "ARIA는 차분하다"·"ARIA는 침착하다"·"HARU는 활발하다" 가 점으로 표시된 모습 — 의미가 비슷한 두 문장은 가까이, 다른 문장은 멀리.

지도 위에서 두 도시의 물리적 거리가 가까우면 우리가 "가깝다" 고 부르죠. 임베딩 공간에서도 같아요. 단, 거리의 기준이 물리적 위치가 아니라 의미 입니다.

  • "ARIA 는 차분하다" → 평면 위 한 점 (예: 좌표 (0.34, -0.18))
  • "ARIA 는 침착한 성격이다" → 거의 같은 곳 (예: (0.33, -0.17))
  • "HARU 는 활발하다" → 멀리 떨어진 지점 (예: (0.71, 0.42))

두 점 사이 거리를 계산하면 의미가 얼마나 비슷한가가 숫자로 나와요.

키워드 일치 (ARIA 가 들어가나? 차분 이 들어가나?) 가 아니라 의미의 가까움 으로 비교하는 게 임베딩의 본질이에요.

그런데 진짜 차원은 768 — 평면이 아니에요

지도 비유에선 2D 평면을 썼지만, 실제 임베딩 모델이 만드는 벡터는 768 차원 또는 1024, 1536 차원입니다.

"768 차원? 그게 뭐예요?" — 학생들이 가장 자주 멈칫하는 곳이에요.

쉽게 말하면 — 한 문장을 표현하기 위해 숫자 768 개를 쓴다는 뜻이에요.

좌표가 2 개 (x, y) 면 평면 위 한 점이고, 3 개 (x, y, z) 면 공간 한 점인 것처럼, 768 개면 768 차원 공간의 한 점이 되는 거예요.

우리가 2D 평면 이상은 머리로 그릴 수 없지만, 컴퓨터는 768 차원에서도 두 점 사이 거리를 똑같이 계산할 수 있어요.

차원이 늘어날수록 문장의 미묘한 의미 차이를 더 풍부하게 담을 수 있고요.

한 줄 코드 — 임베딩 모델이 하는 일

Day 15 의 EmbeddingService 가 하는 일이 딱 한 줄이에요. 미리 한 번 살짝 보여드릴게요.

// lectures/spring-ai/lecture-source-code/ai-friends/.../EmbeddingService.java
// (Day 15 Step 2 에서 본격적으로 다룰 부분)

private final EmbeddingModel embeddingModel;

public float[] embed(String text) {
    return embeddingModel.embed(text);
}

문장 (String text) 이 들어가면 실수 배열 (float[], 길이 768) 이 나옵니다. 그게 다예요. 문장 → 벡터 변환은 임베딩 모델의 본질을 한 줄로 압축한 모습이에요.

코사인 유사도 — 두 벡터의 가까움을 숫자로

두 벡터 사이 가까움을 어떻게 숫자로 잴까요? RAG 에서 표준으로 쓰는 방식이 코사인 유사도 (cosine similarity) 입니다.

코사인 유사도는 두 벡터가 가리키는 방향의 가까움을 -1 ~ 1 사이 값으로 돌려줘요.

  • 1 에 가까울수록 → 두 벡터가 같은 방향 → 의미가 가까움
  • 0 근처 → 두 벡터가 직각 → 의미가 무관함
  • -1 에 가까울수록 → 두 벡터가 정반대 → 의미가 반대

실무에선 코사인 거리 (cosine distance) = 1 - 코사인 유사도 로 변환해 작을수록 가까움으로 통일해 쓰기도 해요. pgvector 도 <=> 연산자로 코사인 거리를 돌려주는 방식입니다.

임베딩 모델은 어떻게 의미가 가까운 위치를 학습하나

"문장이 의미 공간의 한 점이 된다" 는 흐름을 처음 들으면 "그 위치가 어떻게 결정되는 거예요?" 가 자연스레 떠올라요.

임베딩 모델은 보통 대조 학습 (contrastive learning) 이라는 방식으로 훈련돼요. 학습 데이터는 "의미가 가까운 문장 쌍" 과 "의미가 먼 문장 쌍" 이에요.

  • 가까운 쌍: "오늘 날씨가 좋다""오늘 하늘이 맑다"
  • 먼 쌍: "오늘 날씨가 좋다""파이썬으로 머신러닝하기"

모델은 가까운 쌍의 두 벡터가 공간에서 가까이 모이도록, 먼 쌍의 두 벡터가 멀리 떨어지도록 가중치를 학습해요.

수억 개의 쌍을 학습하면 어떤 새로운 문장을 넣어도 의미가 가까운 위치가 자연스레 만들어집니다.

세부 학습 방식은 (Sentence-BERT · SimCSE · E5 · BGE 등) 모델마다 다르지만 공간에서 의미가 가까운 위치라는 본질은 같아요.

본 강의에선 어떻게 학습됐는지보다 어떻게 사용하면 되는지에 초점을 둡니다.

semantic similarity 와 lexical similarity 의 차이

여기서 한 가지 짚어둘 게 있어요. 임베딩 기반 유사도를 semantic similarity (의미 유사도) 라고 부르는데, 이게 전통적인 lexical similarity (어휘 유사도) 와 다릅니다.

두 문장 lexical (단어 겹침) semantic (의미 가까움)
"ARIA 는 차분하다" vs "ARIA 는 침착하다" 단어 1 개 겹침 (낮음) 거의 같음 (매우 높음)
"커피 한 컵 주세요" vs "아메리카노 한 잔 주문이요" 0 단어 겹침 매우 높음
"날씨 좋다" vs "날씨 끔찍하다" 1 단어 겹침 반대 의미 (낮음)

lexical 검색 (예: SQL LIKE '%차분%') 으로는 "침착하다" 를 못 잡아내요.

semantic 검색은 두 문장이 의미상 같은 영역에 있다는 걸 자동으로 알아챕니다. 그게 RAG 가 키워드 검색보다 강한 첫 번째 이유예요.

코드 한 줄 미리보기 — 의미 공간이 코드로

EmbeddingService.embed("ARIA 는 차분하다") 의 반환값이 어떻게 생겼는지 살짝 미리 보여드릴게요. Day 15 에서 직접 돌려봅니다.

[-0.0182, 0.0341, -0.0773, ..., 0.0091]   // 길이 768 의 float 배열

이 배열의 첫 3 개 숫자만 봐도 해석할 수가 없어요. 숫자 자체에 의미가 있는 게 아니라, 다른 문장의 벡터와 비교했을 때 거리가 가까운지가 중요한 거예요.

마치 지도 좌표 (37.5, 127.0) 만 봐서는 그게 서울인지 알 수 없지만, 다른 도시 좌표와 비교하면 서울이 부산보다 인천에 가깝다가 보이는 것과 같아요.

⚠️ 주의: 같은 임베딩 모델로 적재·검색

KB 청크를 적재할 때 쓴 임베딩 모델과 질문을 임베딩할 때 쓴 모델이 반드시 같아야 해요.

다른 모델 두 개의 벡터 공간은 좌표계가 달라서, 한 모델의 서울 좌표와 다른 모델의 서울 좌표가 전혀 다른 위치에 떨어져요. 코사인 거리를 비교하는 의미가 사라집니다.

💡 튜터의 결론

임베딩은 문장을 의미 공간의 한 점으로 옮기는 흐름이에요. 768 개 숫자가 한 문장을 표현하고, 두 점 사이 거리 (코사인 유사도) 가 의미의 가까움을 숫자로 돌려줘요.

다음 Step 에서 그 점들이 수만 개·수십만 개가 됐을 때 어떻게 빠르게 검색하는지 — 벡터 DB 로 갑니다.


Step 3. 벡터 DB 와 ANN — 100만 벡터에서 Top-K 를 빠르게

KB 청크 하나가 벡터 한 개로 저장된다고 했어요. 그럼 청크가 자라서 100,000 개가 되면? 1,000,000 개가 되면?

매 질문마다 100만 개 벡터와 일일이 코사인 거리를 계산하면 응답이 수 초씩 늘어나요. 미연시 게임이 답답해 보이는 두 번째 신호죠.

그래서 RAG 운영에선 벡터 DB (vector database) 라는 전용 저장소를 써요.

보통 일반 RDB (PostgreSQL · MySQL) 가 행 (row) 단위 인덱스로 검색을 빠르게 하듯이, 벡터 DB 는 벡터 단위 인덱스로 검색을 빠르게 해요.

도서관에서 책 찾는 두 가지 방법

비유로 풀어볼게요. 큰 도서관에 책 100만 권이 있어요. "민음사에서 나온 김애란의 단편집" 을 찾으려면 두 가지 방법이 있어요.

왼쪽에는 사서가 첫 책장부터 마지막 책장까지 책 한 권 한 권을 들고 확인하며 지친 모습(전수조사), 오른쪽에는 사서가 분류표를 펼쳐 보고 "한국 소설 → 김애란 → 단편집" 영역으로 곧장 가는 모습(인덱스 검색).

첫 번째 — 전수조사. 첫 책장부터 마지막 책장까지 한 권씩 손에 들고 "이거 맞나?" 확인합니다.

정확하지만 시간이 너무 오래 걸려요. 100만 권이면 사서가 하루 종일 매달려도 못 끝낼 수 있어요.

두 번째 — 분류표 사용. 한국 문학 → 단편 → 김애란 → 민음사 순으로 분류 체계를 따라가요.

책 100만 권 중에서도 분류표가 잡아주는 영역만 보면 되니까 수 초 안에 끝납니다. 단, 분류가 살짝 어긋난 책 한 권은 놓칠 수도 있어요 (예: 잘못 분류된 책).

벡터 DB 도 똑같아요. 전수조사 = 정확하지만 느림. 인덱스 사용 = 빠르지만 약간 근사적. 그래서 Approximate Nearest Neighbor 라는 이름이 붙어요.

ANN — Approximate Nearest Neighbor

ANN (Approximate Nearest Neighbor, 근사 최근접 이웃) 은 정확한 1 등 대신 높은 확률로 진짜 1 등 근처의 결과를 빠르게 돌려주는 방식이에요.

예를 들어 정확한 Top-3 가 [청크A, 청크B, 청크C] 라면, ANN 은 [청크A, 청크B, 청크D] 를 돌려줄 수도 있어요. C 와 D 가 거의 같은 거리에 있는 상태에서 한 번 살짝 어긋난 거예요.

RAG 의 실무에선 이 정도 어긋남은 응답 품질에 거의 영향이 없습니다 (어차피 LLM 이 N 개 청크를 보고 답을 합치니까요).

HNSW vs IVFFlat — 두 가지 인덱스의 트레이드오프

2026 년 pgvector 의 표준 ANN 인덱스가 두 가지예요. 둘의 트레이드오프를 한 표로 정리해 둘게요.

HNSW IVFFlat
풀네임 Hierarchical Navigable Small World Inverted File with Flat compression
검색 속도 매우 빠름 빠름 (HNSW 보다 약간 느림)
적재 (인덱스 빌딩) 비용 큼 (느림) 작음 (빠름)
메모리 사용 작음
데이터 추가 (incremental insert) 매끄러움 인덱스 재빌딩 권장
디폴트 추천 🌟 학습 + 중소 운영 수백만+ 청크 대규모 운영

학생 단계에선 HNSW 가 무난한 디폴트예요. 검색이 빠르고, 데이터 추가가 매끄럽거든요. 적재 비용은 학습 규모 (수백~수만 청크) 에선 체감이 거의 없어요.

HNSW 는 계층적 그래프 (위층에서 큰 점프, 아래층에서 미세 조정), IVFFlat 는 클러스터 분할 (벡터를 N 개 클러스터로 나누고 가까운 클러스터만 탐색).

운영 규모 (수백만~수천만 청크) 에 가면 두 인덱스의 트레이드오프를 다시 따져야 해요. 본 강의 범위를 넘어가니, 별도 벡터 DB 운영 과정에서 깊게 다룰 영역입니다.

HNSW 의 작동 원리 — 계층적 그래프 한 줄로

조금만 더 깊게. HNSW 의 H 가 Hierarchical (계층적) 인 이유를 한 줄로 풀어볼게요.

맨 위층은 점이 5 개, 중간층은 50 개, 맨 아래층은 모든 벡터 (예: 100,000 개) 가 점으로 표시된 모습. 검색 시 맨 위층에서 시작해 점프하며 내려와 맨 아래층에서 미세 조정하는 화살표.

HNSW 는 벡터들을 계층적 그래프로 구성해요. 위층은 점이 적고 (큰 점프 가능), 아래층은 점이 많아요 (미세 조정).

검색은 맨 위층의 한 점에서 시작 → 위층에서 점프 점프 → 가까운 곳 찾기 → 내려가서 미세 조정 → 맨 아래층의 진짜 1 등 찾기의 흐름으로 돌아갑니다.

이 방식이 대수적 복잡도를 끌어내려요. 전수조사가 100만 청크에서 100만 번 비교라면, HNSW 는 약 log(100만) = 20 번 비교 정도로 끝나요. 5만 배 빨라지는 셈.

단점은 인덱스 빌딩 비용. 100만 청크의 계층 그래프를 처음 만들 때 시간이 약간 걸려요. 한 번 만들어두면 검색은 평생 빨라요. 학습 규모에선 빌딩 비용이 거의 체감되지 않습니다.

Top-K — 가장 가까운 K 개를 꺼낸다

벡터 DB 검색의 결과는 가장 가까운 K 개의 청크예요. K 는 보통 3, 5, 10 정도. 이걸 Top-K 라고 불러요.

  • K=1 — 가장 가까운 한 개만 → 정확하지만 정보가 부족할 수 있음
  • K=5 — 위쪽 5 개 → 학습용 디폴트, 다양한 관점 포착
  • K=20 — 위쪽 20 개 → 추가 토큰 비용 + LLM 이 노이즈에 휘둘릴 수 있음

K 의 트레이드오프는 정확도 ↔ 비용·노이즈의 균형이에요. 다음 시간 Day 15·16 에서 K 값을 바꿔가며 검색 품질이 어떻게 바뀌는지 직접 돌려보는 시간이 있어요.

코드 한 줄 미리보기 — VectorStore 의 검색 메서드

Day 15 의 VectorStore 빈이 검색을 어떻게 노출하는지 미리 한 줄.

// lectures/spring-ai/lecture-source-code/ai-friends/.../VectorStoreConfig.java
// (Day 15 Step 4 에서 다룰 부분)

@Bean
public VectorStore vectorStore(JdbcTemplate vectorStoreJdbcTemplate, EmbeddingModel embeddingModel) {
    return PgVectorStore.builder(vectorStoreJdbcTemplate, embeddingModel)
            .dimensions(768)
            .indexType(PgVectorStore.PgIndexType.HNSW)
            .distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE)
            .initializeSchema(true)
            .build();
}

indexType(HNSW) + distanceType(COSINE_DISTANCE) — 오늘 익힌 두 개념이 빌더 한 줄에 그대로 들어 있어요. 다음 시간 그 부분에서 다시 만납니다.

💡 튜터의 결론

100만 벡터에서 Top-K 를 빠르게 찾으려면 인덱스가 필요해요. ANN (HNSW · IVFFlat) 이 정확함 대신 속도를 가져오는 방식이고, 그 약간의 근사성은 RAG 응답 품질에 영향이 거의 없어요.

학습 디폴트는 HNSW + COSINE_DISTANCE 의 조합입니다.


Step 4. R·A·G 세 글자 분해 + Naive vs Modular RAG

이제 RAG 의 풀네임을 정확히 펼쳐볼 시간이에요.

Retrieval-Augmented Generation — 검색-증강 생성.

세 글자가 각각 어떤 일을 하는지, 어디서 잘리고 어디서 이어지는지 분해해 봅시다.

R · A · G 한 글자씩

Retrieval (검색)

  • 마스터의 질문을 받아, 같은 임베딩 모델 로 벡터를 만들어요.
  • 벡터 DB 에서 그 벡터와 의미가 가까운 청크 Top-K 개를 꺼냅니다.
  • 출력: K 개의 청크 (각각 텍스트 + metadata)

Augmented (증강)

  • 꺼낸 청크를 LLM 에 넘기는 프롬프트에 추가 컨텍스트 로 끼워 넣어요.
  • 보통 "다음 문서를 참고해서 답해줘: [청크1] [청크2] [청크3]" 같은 시스템 프롬프트 위치에 들어갑니다.
  • 출력: 청크가 들어간 완성된 프롬프트

Generation (생성)

  • LLM 이 그 프롬프트를 받아 답을 생성해요.
  • 답의 근거가 청크 안에 있으니까, 모델이 모르는 부분을 지어내는 (환각, hallucination) 위험이 줄어요.
  • 출력: 사용자에게 돌려줄 응답 텍스트

한 번 더 — 일상 비유로 R·A·G 풀어보기

세 글자가 약간 추상적으로 들릴 수 있어서 일상 비유 한 번 더 던져볼게요.

"어제 김애란 단편 추천해주세요" 라는 질문에 (1) 사서가 분류표로 책 3 권을 골라오고 (R), (2) 책 세 권을 책상에 펼쳐 놓고 (A), (3) "이 세 권 중에는 호두 라떼 같은 단편이 있어요" 라고 사서가 종합해서 답하는 (G) 흐름.

도서관 사서가 손님의 질문 "어제 김애란 단편 추천해주세요" 에 답하는 흐름을 떠올려 보세요.

  • R (검색) — 사서가 분류표를 펼쳐 "한국 문학 → 단편 → 김애란" 영역에서 책 3 권을 골라옵니다.
  • A (증강) — 책 세 권을 책상 위에 펼쳐 놓고 "이 세 권 사이에서 답해줘" 의 상태로 준비합니다.
  • G (생성) — 사서가 세 권의 내용을 종합해 "호두 라떼 같은 단편이 있어요 — 비행운 추천드려요" 의 답을 만듭니다.

LLM 의 RAG 와 1:1 로 겹쳐요. 단지 책 = 청크, 분류표 = 벡터 인덱스, 사서 = ChatModel 로 매핑이 바뀌었을 뿐이에요.

한 장 플로우차트

(1) 마스터 질문 "어제 약속이 뭐였더라?" → (2) EmbeddingModel 이 질문을 768 차원 벡터로 변환 → (3) pgvector VectorStore 가 Top-K 청크 검색.

→ (4) "다음 문서를 참고해 답해줘 + 청크1 + 청크2 + 청크3" 프롬프트 빌드 → (5) ChatModel (LLM) 이 응답 생성 → (6) 마스터에게 답변 전달.

(1)(2)(3) 위에 "R" 라벨, (4) 위에 "A" 라벨, (5)(6) 위에 "G" 라벨.

이 플로우는 RAG 의 가장 기본 형태예요. 한 번의 질문 → 한 번의 검색 → 한 번의 생성. 이걸 Naive RAG 라고 불러요.

Naive RAG vs Modular RAG — 본질 차이 한 단락

2026 년 RAG 운영에선 두 가지 방식이 정착해 있어요.

Naive RAG — 위 플로우차트 한 줄. 검색 → 증강 → 생성이 직선 한 번이에요. 단순하고 빠르고 디버깅이 쉬워요. 학습 + 중소 규모 운영에 충분합니다. 본 강의 Day 15 의 첫 그림 이에요.

Modular RAG — 검색 부분을 advisor 체인으로 분해해서, 질문 재작성 → 검색 → 재랭킹 (re-ranking) → 압축 → 생성 같이 단계별로 모듈을 끼워 넣어요.

각 단계가 독립된 부품 (Spring AI 의 advisor 거울) 으로 살아 있어서 한 부품만 갈아 끼우는 유연성이 큽니다. 깊은 비교는 본 강의 Day 16 에서 본격적으로 다룹니다.

Naive RAG Modular RAG
구성 검색 → 증강 → 생성 직선 한 번 advisor 체인 단계 분해 (재작성·재랭킹·압축·...)
단순성 매우 단순 단계 N 개만큼 복잡
검색 품질 청크 품질에 직접 의존 단계별 보정으로 품질 ↑
토큰 비용 K 청크만 재랭킹·압축으로 더 줄일 수도 있음
학습 디폴트 🌟 Day 15·16 의 첫 그림 Day 16 후반·Day 17 이후

핵심 메시지 — "Naive 가 부족해서 Modular 로 가는 게 아니에요".

Naive 가 어디까지 충분한지를 먼저 익히고, 한계가 보일 때 모듈 한 개씩만 더 끼우는 흐름이 운영 친화적입니다. 처음부터 모듈을 다 끼우면 디버깅이 어려워져요.

Naive RAG 가 한계를 보이는 곳 — 4 가지 신호

Naive 가 어디까지 충분한지를 익히는 방법은 한계가 보이는 신호를 알아두는 거예요. 운영에서 Modular 의 모듈을 끼워야 할 시점이 보이는 4 가지 신호를 정리해 둘게요.

신호 1 — 질문이 짧고 모호하다

마스터의 질문이 "어제 일기" 처럼 짧은 한 단어면 임베딩이 잡아내는 의미 영역이 좁아져요. 답이 어제 일기 전체인지 어제 적은 약속 부분인지 LLM 이 헷갈리는 사고가 나요.

질문 재작성 (query rewriting) 모듈이 "어제 적은 약속과 관련된 일기 내용을 알려줘" 로 풀어 검색을 도와요.

신호 2 — Top-K 가 너무 다양한 영역에 흩어진다

검색 결과 5 개가 서로 무관한 5 가지 주제라면 LLM 이 종합하기 어려워요. 재랭킹 (re-ranking) 모듈이 질문-청크 한 쌍씩 더 정확한 점수를 매겨 진짜 가까운 N 개만 남겨요.

신호 3 — 청크가 너무 길어서 토큰이 폭발한다

청크 5 개가 합쳐 5,000 토큰이라면 LLM 컨텍스트가 차서 응답이 짧아져요. 컴프레션 (compression) 모듈이 청크의 질문과 관련된 부분만 추출해 토큰을 줄여요.

신호 4 — 답이 청크 안에 명시적으로 안 적혀 있다

마스터가 "오늘은 ARIA 가 기분이 어땠어?" 라고 물었는데 일기에 "오늘 ARIA 가 카페에서 한참 웃었다" 만 적혀 있다면? 답은 "기분이 좋았다" 인데 LLM 이 추론으로 답해야 해요.

이건 RAG 의 한계 — 복잡한 추론은 다른 패턴 (Day 14 의 Agent · Tool Calling 결합) 으로 풀어야 합니다.

네 가지 신호를 알고 있으면 언제 Naive 가 무너지는지가 보여요. 그때까지는 Naive 만으로 충분합니다.

"환각 (hallucination)" — RAG 가 줄여주는 부분

용어 하나 더 정리해 둘게요. LLM 이 자기가 모르는 영역에서도 그럴듯하게 답을 지어내는 현상을 환각 (hallucination) 이라고 불러요.

ARIA 에게 "어제 일기에 뭐라고 적었지?" 라고 물었을 때, LLM 가중치 안에 답이 없으면 두 가지 식으로 갈려요.

  • 환각 — "어제 일기에 호두 라떼를 마셨다고 적으셨네요" (사실이 아니지만 그럴듯하게 지어냄)
  • 솔직 — "제 기억엔 한정된 컨텍스트만 있어서, 어제 일기는 짚어드리기 어려워요" (Day 14 의 ARIA)

LLM 운영에서 환각은 가장 큰 사고 영역이에요. 사용자가 사실로 믿어버리면 신뢰가 깨지거든요. RAG 는 답의 근거가 청크 안에 있음을 강제해서 환각을 상당히 줄여줘요.

단 완전히 없애주진 않아요. 청크 안에 답이 없을 때도 LLM 이 청크 내용을 과장 해석하는 사고가 남아 있어요.

이걸 줄이는 방법이 프롬프트 엔지니어링 + 재랭킹 + citation (출처 표시) 같은 Modular RAG 의 모듈들이에요. Day 16 의 본격 다룸 영역.

Spring AI 의 advisor 가 어디 들어가는가

Day 16 에 가면 RAG 의 AG 부분이 한 묶음 advisor 로 자동화돼요. 이름이 RetrievalAugmentationAdvisor 입니다 (또는 더 얇은 QuestionAnswerAdvisor).

advisor 체인의 위치를 한 줄 그림으로 정리하면 —

User Message
   ↓
[advisor 1: QuestionAnswerAdvisor or RetrievalAugmentationAdvisor]
   │  ─ (R) 질문을 임베딩 → VectorStore.similaritySearch(K)
   │  ─ (A) 청크를 시스템 프롬프트에 끼움
   ↓
[advisor 2: ChatMemory ...]
   ↓
ChatModel.call() ─ (G)
   ↓
Response

오늘은 advisor 의 모양만 잡아두세요. 코드는 Day 16 에서 직접 다룹니다.

💡 튜터의 결론

R·A·G 는 검색 → 증강 → 생성의 세 단계예요. Naive RAG (직선 한 번) 가 학습·중소 운영의 첫 그림이고, Modular RAG (단계 분해) 는 한계가 보일 때 모듈 한 개씩만 더 끼우는 흐름이에요.

Spring AI 의 RetrievalAugmentationAdvisor 가 다음 시간 그 자동화를 한 묶음으로 해줘요.


Step 5. 청킹과 TokenTextSplitter — 큰 문서를 의미 단위로 자르기

여기까지 임베딩 · 벡터 DB · R·A·G 분해를 익혔어요. 마지막 부품 하나가 남았어요. KB 가 처음 들어올 때 어떤 단위로 벡터화될지를 결정하는 부분 — 청킹 (chunking) 입니다.

왜 통째로 임베딩하면 안 되는가

비유 하나 던져볼게요. 두꺼운 책 한 권을 통째로 검색하고 싶다고 합시다.

옵션 A — 책 한 권 통째 임베딩. 책 전체를 한 벡터 (float[768]) 로 만들어요. 문제는?

  • 벡터가 책 전체의 평균 의미를 담아요. "5 장 7 절의 김애란 단편 분석" 같은 특정 부분 의미는 평균에 묻혀 사라져요.
  • 검색해도 "이 책 전체와 관련 있다" 만 알 수 있을 뿐, 어느 페이지가 답인지 모릅니다.
  • LLM 에 청크로 넘길 때 책 전체를 넘기면 토큰 비용이 폭발해요.

옵션 B — 책을 페이지 단위로 분할 임베딩. 한 페이지씩 벡터를 만들어요. 검색하면 "5 장 7 절 페이지가 답이다" 가 바로 잡혀요. LLM 에 넘길 때도 그 한 페이지만 흘려 보내면 됩니다.

청킹은 옵션 B 의 방식이에요. KB 문서를 의미 단위 (chunk) 로 잘게 쪼개서 각각 임베딩하는 흐름.

Before / After 한 묶음

ai-friends 의 KB 예시로 풀어볼게요. ARIA 의 캐릭터 프로필 마크다운 파일이 10,000 토큰 분량이라고 합시다.

Before — 통째 임베딩

aria-profile.md (10,000 토큰)
  → 한 개의 vector (768 차원)
  → 검색 시 "이 파일 전체" 만 잡힘
  → LLM 컨텍스트에 10,000 토큰 통째로 흘려보냄 (비용 폭발)

After — 청킹 후 임베딩

aria-profile.md (10,000 토큰)
  → TokenTextSplitter (chunk=500, overlap=50)
  → 약 20 개의 chunk
  → 각 chunk → 한 개의 vector
  → 검색 시 "MBTI 가 INTJ 인 이유" 청크 한 개만 잡힘
  → LLM 컨텍스트에 500 토큰만 흘려보냄

청크 수가 늘어나지만, 검색이 의미 단위로 정확해지고 토큰 비용도 줄어요. RAG 의 본질적 가치가 여기서 살아납니다.

청크 크기와 오버랩의 트레이드오프

청크 크기 (chunk size) 와 오버랩 (overlap) — 두 파라미터를 어떻게 정할지가 RAG 검색 품질의 핵심이에요.

청크 크기 (chunk size) — 한 청크가 몇 토큰을 담을지.

청크 크기 특성 적합한 상황
100~200 토큰 짧은 문장 · 정확한 매칭 짧은 답 (FAQ · 단답형)
🌟 500 토큰 한 문단~한 문단 반 · 학습 디폴트 일반 KB · 캐릭터 프로필
1,000~2,000 토큰 긴 문맥 · 추론 필요 영역 논문 · 보고서 · 코드 청크

오버랩 (overlap) — 청크 간 겹치는 토큰 수. 청크 경계에 걸친 문장이 두 청크 모두에 살아남게 하는 장치입니다.

chunk 500 · overlap 50 일 때, 첫 청크가 토큰 1~500 을 담고, 다음 청크가 토큰 451~950 을 담아 450~500 구간이 두 청크에 동시에 들어가는 모습.

오버랩이 없으면 문장이 청크 경계에서 잘려서 의미가 깨져요 ("ARIA 는 INTJ 라서" / "분석적이다" 가 다른 청크로 갈리면 두 청크 다 단독으로는 약함).

오버랩이 너무 크면 같은 문장이 여러 청크에 중복돼서 토큰 낭비 + 검색 결과에 같은 문장이 여러 번 등장하는 사고가 나요.

학습 디폴트는 chunk=500 · overlap=50 (10% 오버랩) 입니다. Day 15 의 TokenTextSplitter 가 그 값으로 설정돼 있어요.

손계산 — 같은 KB 를 세 가지 크기로 자르면

가상의 KB 한 묶음 (총 10,000 토큰) 을 세 가지 청크 크기로 자르면 청크 수가 어떻게 변할지 손으로 한번 계산해 봅시다.

청크 크기 오버랩 청크 수 (대략) 청크 한 개의 토큰 Top-K=5 의 총 토큰
200 20 약 55 개 200 1,000
🌟 500 50 약 22 개 500 2,500
1,000 100 약 11 개 1,000 5,000

청크 크기가 200 → 1,000 으로 5 배 커지면, 청크 수는 1/5 로 줄지만 K=5 의 총 토큰은 5 배 늘어요. 검색 정밀도와 프롬프트 토큰 비용이 정확히 반대 방향으로 움직입니다.

또 하나 — 청크가 작으면 질문 한 줄과 같은 영역의 청크가 K=5 안에 더 다양하게 잡혀요. 청크가 크면 한 의미 단위가 통째로 들어와서 LLM 의 추론이 더 풍부해질 수 있어요.

어느 쪽이 좋은지는 질문의 평균 길이와 KB 의 의미 단위 크기에 달려 있어요. 본 Day 의 생각해볼 주제 3 에서 다시 고민해 봅시다.

토큰 vs 글자 — 한국어의 미묘한 지점

용어 하나 더 정리해 둘게요. 청크 500 토큰의 토큰은 무엇일까요?

LLM 의 입력 단위가 토큰 (token) 이에요. 영어는 보통 공백 단위 + 자주 쓰이는 어미·접미사 분리로 토큰화돼요. 한국어는 좀 더 미묘해서 — 보통 서브워드 단위로 깨져요.

예:

  • 안녕하세요 → 토큰 1 개 (자주 쓰이는 형태라 통째)
  • 안녕하시었나요 → 토큰 3~4 개 (덜 자주 쓰여서 분할)
  • RAG 임베딩 입문 → 토큰 5~7 개 (영어·한국어 혼합)

그래서 청크 500 토큰은 한국어로 대략 공백 기준 300~400 어절 정도예요. 한 문단~한 문단 반 정도 분량.

💡 튜터의 노트

한국어 RAG 운영에서 가장 신경 쓰이는 부분이 문장 경계가 청크 안에서 깨지는 사고예요. TokenTextSplitter 는 토큰 단위라 문장 경계를 보존하지 않아요.

본격 운영에선 sentence-aware splitter (마침표·물음표 단위 분할) 와의 트레이드오프를 비교하게 되는데, 그 부분은 Day 16 의 본격 다룸 영역입니다.

학습 디폴트로는 TokenTextSplitter 한 줄이면 충분해요.

청크 크기 결정 — 3 가지 가이드 라인

청크 크기를 정할 때 디폴트 500 토큰 외에 운영에서 자주 쓰는 가이드 라인 3 가지를 정리해 둘게요.

가이드 1 — KB 의 의미 단위가 평균 얼마인가

마크다운 한 문단이 의미 단위라면 한 문단의 토큰 수 (보통 200~400 토큰) 의 1.5 배쯤이 적절해요. 한 의미 단위가 한 청크 안에 통째로 들어가게 하는 방식입니다.

가이드 2 — 질문의 평균 길이는 얼마인가

질문이 짧은 (50 토큰 내외) FAQ 형이라면 청크도 짧게 (200~300 토큰) 두는 게 검색 매칭 정밀도에 좋아요.

질문과 청크의 토큰 분포가 비슷할 때 임베딩 모델의 의미 매칭이 더 정확하게 작동해요.

가이드 3 — LLM 컨텍스트 토큰 예산

Top-K=5 일 때 청크 5 개 + 시스템 프롬프트 + 질문 + 응답 여유분이 모델의 컨텍스트 윈도우를 과반 이상 차지하지 않게 두세요.

4K 모델이면 청크 5 개가 2K 를 넘지 않게 — 청크 한 개 400 토큰 미만.

세 가지를 동시에 만족시키는 값이 학습 디폴트 500 토큰이에요. 운영 환경에서는 KB 의 특성에 따라 200 ~ 1,000 사이로 갈리고요.

코드 한 줄 미리보기 — TokenTextSplitter

Day 15 의 DocumentLoaderService 안에서 청킹이 어떻게 작성되는지 미리 한 줄.

// lectures/spring-ai/lecture-source-code/ai-friends/.../DocumentLoaderService.java
// (Day 15 Step 5 에서 다룰 부분)

public static final int DEFAULT_CHUNK_TOKENS = 500;
public static final int DEFAULT_CHUNK_OVERLAP = 50;

TokenTextSplitter splitter = new TokenTextSplitter(
        DEFAULT_CHUNK_TOKENS,
        DEFAULT_CHUNK_OVERLAP,
        /* minChunkLengthToEmbed */ 5,
        /* maxNumChunks */ 10_000,
        /* keepSeparator */ true);
List<Document> chunks = splitter.apply(rawDocuments);

50050 — 오늘 익힌 디폴트가 빌더 인자에 그대로 흘러갑니다. 다음 시간 그 곳에서 다시 만나요.

💡 튜터의 결론

청킹은 KB 문서를 의미 단위로 잘게 쪼개서 각각 임베딩하는 흐름이에요. 청크 크기 + 오버랩의 트레이드오프가 RAG 검색 품질의 핵심 변수입니다.

학습 디폴트는 chunk=500·overlap=50. 한국어 운영에선 sentence-aware splitter 와의 비교가 다음 단계의 고민이에요.


Step 6. Day 15·16 학습 로드맵 — 5 개념이 코드로 자라는 모습

여기까지 5 개념을 익혔어요. 한 줄씩 다시 짚고 — 그 다음에 각 개념이 다음 시간 어느 코드로 자라는지 로드맵을 그려볼게요.

개념 한 줄 본질 Step
외부 지식 주입 LLM 가중치 밖의 자료를 KB 에 두고 질문마다 꺼낸다 Step 1
임베딩 문장 → 768 차원 벡터 (의미 공간의 한 점) Step 2
벡터 DB + ANN 100만 벡터에서 Top-K 를 빠르게 — HNSW · IVFFlat Step 3
R·A·G 분해 검색 → 증강 → 생성 직선 (Naive) 또는 모듈 분해 (Modular) Step 4
청킹 큰 문서를 chunk·overlap 단위로 잘게 — TokenTextSplitter Step 5

한 번 더 복습 — 5 개념이 한 흐름으로 이어지는 모습

코드로 자라는 모습을 보기 전에, 5 개념이 한 RAG 사이클에서 어떻게 이어지는지 한 번 더 짚어볼게요.

마스터가 "어제 약속이 뭐였더라?" 한 줄을 던졌을 때 ai-friends 내부에서 도는 흐름이에요.

  1. 외부 지식 주입의 필요 — ARIA 가 이 질문에 답하려면 어제의 일기가 외부에 저장돼 있어야 한다.
  2. 임베딩 — 마스터의 질문 "어제 약속이 뭐였더라?" 가 EmbeddingService.embed(...) 한 줄로 768 차원 벡터가 된다.
  3. 벡터 DB + ANN — pgvector 안의 HNSW 인덱스가 그 벡터와 가까운 청크 Top-K=5 를 빠르게 꺼낸다.
  4. R·A·G — 꺼낸 청크 5 개가 시스템 프롬프트에 끼워 들어가고 (A), ChatModel 이 그 컨텍스트 위에서 답을 생성한다 (G).
  5. 청킹 — 그런데 적재 시점에 마스터의 일기가 chunk=500·overlap=50 으로 잘려 있어야 위의 흐름이 도는 거예요. 청킹은 시간상 가장 먼저 일어나는 단계.

다섯 단계가 한 사이클의 다른 시점에 동작해요. 5 (청킹) 는 적재 시점, 1 회만. 1·2·3·4 는 질문 시점, 매 호출마다.

두 흐름이 만나는 지점이 vectorStore.add(chunks) (적재) 와 vectorStore.similaritySearch(query) (검색) 두 메서드입니다.

왼쪽(적재 시점, 1회) 에 "마크다운 파일 → 청킹 → 임베딩 → pgvector 적재" 의 4 단계, 오른쪽(질문 시점, 매번) 에 "마스터 질문 → 임베딩 → pgvector 검색 → 청크 5 개 → LLM → 응답" 의 6 단계. 두 흐름이 pgvector 박스를 공유하며 가운데서 만남.

Day 15·16 로드맵 — 5 개념이 코드로 자라는 모습

왼쪽에 오늘 익힌 5 개념 박스가 세로로 정렬, 오른쪽에 Day 15·16 의 코드 부품 (EmbeddingService · VectorStoreConfig · DocumentLoaderService · CharacterKnowledgeIngestionService · RetrievalAugmentationAdvisor) 박스가 정렬, 두 묶음 사이에 화살표로 어느 개념이 어느 부품으로 자라는지 연결되는 그림.

Day 15 — RAG 의 R 부분을 손으로

오늘 익힌 개념 Day 15 의 코드 부품 무슨 일을 함
임베딩 EmbeddingService embed(text) → float[768] 한 줄 게이트웨이
벡터 DB + ANN VectorStoreConfig + pgvector PgVectorStore 빈 손등록, HNSW 인덱스 자동 생성
청킹 DocumentLoaderService TokenTextSplitter(500, 50, ...) 로 KB 분할
KB 적재 CharacterKnowledgeIngestionService vectorStore.add(chunks) 한 줄로 일괄 적재
Top-K 검색 EmbeddingProbeController /api/rag/search?query=... 로 결과 확인

Day 15 는 RAG 의 R 단계를 끝까지 손으로 돌려보는 시간이에요. KB 한 묶음을 pgvector 에 넣고, 마스터의 질문을 임베딩해서 Top-K 청크가 의미 가까운 순으로 돌아오는 모습까지.

Day 16 — A 와 G 의 자동 결합 + Modular RAG 의 첫 모듈

오늘 익힌 개념 Day 16 의 코드 부품 무슨 일을 함
R·A·G 자동 결합 RetrievalAugmentationAdvisor advisor 한 줄에 R+A+G 가 자동 흐름
짧은 advisor QuestionAnswerAdvisor 가벼운 QA, 단일 호출
metadata 필터링 character_id == 'ARIA' 같은 pgvector 위에서 캐릭터별 KB 분리
리랭킹 (re-ranking) Modular RAG 의 첫 모듈 Top-K 결과를 더 가까운 순으로 재정렬
Top-K 튜닝 similarityThreshold · topK 파라미터 검색 품질 vs 토큰 비용 균형

Day 16 은 RAG 의 A + G 단계를 advisor 가 한 묶음으로 해결하고, Modular 의 첫 모듈 (재랭킹) 을 끼워 넣어 검색 품질을 한 단계 끌어올리는 시간이에요.

코드 한 묶음 미리보기 — Day 15 가 어떻게 자랄지

세 부품이 어떻게 한 흐름으로 이어지는지 살짝 미리 보여드릴게요.

// (1) EmbeddingService — 문장을 벡터로
// lectures/spring-ai/.../rag/service/EmbeddingService.java

@Service
@RequiredArgsConstructor
public class EmbeddingService {
    private final EmbeddingModel embeddingModel;  // 인터페이스 주입

    public float[] embed(String text) {
        return embeddingModel.embed(text);
    }
}
// (2) VectorStoreConfig — pgvector + HNSW 인덱스 손등록
// lectures/spring-ai/.../rag/config/VectorStoreConfig.java

@Bean
public VectorStore vectorStore(JdbcTemplate vectorStoreJdbcTemplate, EmbeddingModel embeddingModel) {
    return PgVectorStore.builder(vectorStoreJdbcTemplate, embeddingModel)
            .dimensions(768)
            .indexType(PgVectorStore.PgIndexType.HNSW)
            .distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE)
            .initializeSchema(true)
            .build();
}
// (3) CharacterKnowledgeIngestionService — 청크를 벡터 DB 에 적재
// lectures/spring-ai/.../rag/service/CharacterKnowledgeIngestionService.java

public int ingest() {
    List<Document> chunks = documentLoaderService.loadAndChunk();
    vectorStore.add(chunks);   // 한 줄로 N 개 청크 → 벡터화 → 적재
    return chunks.size();
}

세 부품이 오늘 익힌 5 개념 위에 한 줄 한 줄 자라는 모습이 보이시죠?

  • (1) — 임베딩 개념이 EmbeddingModel 인터페이스 한 줄로
  • (2) — 벡터 DB + ANN + 코사인 거리가 빌더 한 묶음으로
  • (3) — 청킹 + Top-K 적재가 loadAndChunk() + add() 한 줄로

오늘 익힌 흐름 다이어그램

위쪽에 "KB 적재 (1회)" 흐름 (마크다운 파일 → 청크 분할 → 임베딩 → pgvector 적재), 아래쪽에 "질문 처리 (매 호출)" 흐름 (사용자 질문 → 임베딩 → pgvector Top-K 검색 → 청크 N 개 → LLM 컨텍스트 → 응답). 두 흐름이 pgvector 박스를 공유하며 만나는 모양.

면접 키워드 — RAG 첫 만남에서 묻는 질문들

본 강의 졸업 후 면접에서 RAG 관련 질문이 들어왔을 때 짧고 정확히 답할 수 있는 한 줄들이에요.

🎯 면접관을 홀리는 핵심 멘트

"RAG 의 본질은 비용·속도·갱신성 3 축을 한 번에 푸는 외부 지식 주입입니다. LLM 컨텍스트에 KB 를 통째로 넣으면 셋 다 깨져요.

임베딩으로 의미 공간에 매핑하고, ANN 인덱스 (HNSW) 로 Top-K 를 빠르게 꺼내, 청크 N 개만 프롬프트에 끼워 넣습니다."

🎯 면접관을 홀리는 핵심 멘트

"적재 임베딩 모델과 검색 임베딩 모델은 반드시 같아야 합니다. 다른 모델의 벡터 공간은 좌표계가 달라 코사인 거리가 의미가 없어요.

운영 중 모델을 갈아끼우려면 전체 KB 재임베딩이 동반됩니다."

🎯 면접관을 홀리는 핵심 멘트

"Naive RAG 가 어디까지 충분한지 먼저 익히고, 한계가 보일 때 Modular 의 모듈 한 개씩만 더 끼우는 흐름이 운영 친화적입니다. 처음부터 모듈을 다 끼우면 디버깅이 깊어져요."

다음 시간 Day 15 — 직접 손으로 짜는 시간

다음 시간 Day 15 부터는 오늘 익힌 5 개념 위에 코드를 한 줄씩 얹어요.

키워드 미리 잡아두면 — EmbeddingService · pgvector · PgVectorStore · TokenTextSplitter · HNSW 인덱스 다섯 가지입니다.

docker-compose 에 pgvector 컨테이너 한 줄이 추가되고, application.yml 에 임베딩 프로파일 한 묶음이 들어가요.

Ollama 로컬 (nomic-embed-text) 과 Gemini (gemini-embedding-001) 양쪽 다 돌려보고, KB 한 묶음을 적재해서 마스터의 질문에 의미 가까운 청크 Top-K 가 돌아오는 모습까지 손으로 확인합니다.

💡 튜터의 결론

오늘 5 개념을 비유로 익혔어요.

다음 시간 Day 15 의 EmbeddingService · VectorStoreConfig · DocumentLoaderService · CharacterKnowledgeIngestionService 네 부품이 오늘 빈 슬롯 위로 자연스럽게 떨어질 거예요.

코드를 보는 눈이 "이게 뭐지?" 가 아니라 "아, 이 부분이 그거구나" 로 바뀌어 있는 모습 — 그게 오늘 한 번 정리하고 간 가장 큰 가치예요.


마무리

오늘의 Step 한 줄 요약

Step 한 줄 요약
Step 1 RAG 가 필요한 이유 — Day 14 ARIA 의 한계 + 비용·속도·갱신성 3 축
Step 2 임베딩 — 문장을 768 차원 의미 공간의 한 점으로 옮기는 모습
Step 3 벡터 DB + ANN — HNSW · IVFFlat 트레이드오프, Top-K 검색
Step 4 R·A·G 세 글자 분해 + Naive vs Modular RAG 의 본질 차이
Step 5 청킹과 TokenTextSplitter — chunk·overlap 트레이드오프
Step 6 Day 15·16 로드맵 — 5 개념이 코드로 자라는 모습

오늘 익힌 5 개념 흐름 다이어그램

좌측의 5 개념 시퀀스: (1) "외부 지식 주입의 필요" → (2) "임베딩: 문장 → 벡터" → (3) "벡터 DB + ANN: Top-K 빠르게" → (4) "R·A·G 분해: 검색→증강→생성" → (5) "청킹: 큰 문서를 의미 단위로".

다이어그램 우측에는 다음 시간 Day 15 의 4 부품 (EmbeddingService · VectorStoreConfig · DocumentLoaderService · CharacterKnowledgeIngestionService) 이 5 개념과 화살표로 연결되어 흐르는 모습.

등장 용어 카탈로그

오늘 익힌 20 개 용어를 한눈에. 다음 시간 Day 15·16 에서 코드와 함께 다시 만나는 단어들이에요.

분류 용어
한계 · 필요 컨텍스트 윈도우 · LLM 가중치 · 외부 지식 · KB (Knowledge Base) · 비용·속도·갱신성 3 축
임베딩 임베딩 · 벡터 · 차원 (768) · 코사인 유사도 · 의미 공간 · semantic similarity vs lexical similarity
벡터 DB ANN (Approximate Nearest Neighbor) · HNSW · IVFFlat · 인덱스 빌딩 비용 · Top-K
R·A·G Retrieval · Augmented · Generation · Naive RAG · Modular RAG
청킹 청크 · 오버랩 · 토큰 · TokenTextSplitter · 청크 크기 트레이드오프
Day 16 복선 DocumentReader · QuestionAnswerAdvisor · RetrievalAugmentationAdvisor · metadata · 리랭킹 (re-ranking)

한국어 RAG 의 특수성 — 짧게 짚고 가요

본 강의의 KB 는 한국어 자료가 절반 이상이에요. 한국어 RAG 운영에서 특히 신경 쓰이는 부분 3 가지를 정리해 둘게요.

임베딩 모델의 한국어 지원 — 모델마다 다르다

nomic-embed-text 는 다국어를 지원하지만 영어 중심으로 학습돼서 한국어의 미묘한 어미·조사를 잘 못 잡아낼 때가 있어요.

Gemini 의 gemini-embedding-001 은 다국어 학습 비중이 높아 한국어가 잘 나오는 편.

운영에선 한국어 특화 모델 (KoSentenceBERT 등) 을 별도로 두는 경우도 있어요.

조사·어미가 키워드 매칭을 흐린다 — lexical 검색의 한국어 한계

전통 검색 (Elasticsearch · MySQL LIKE) 으로 "ARIA 의 차분함" 을 찾으면 "ARIA 는 차분하다" 를 못 잡아요. 의, 는, 를 같은 조사가 결합형이라 lexical 검색에선 정밀도가 떨어져요.

semantic 검색 (임베딩) 이 여기서 한국어 RAG 의 핵심 강점이 됩니다.

청킹 — 문장 경계가 잘리는 사고

한국어 문장은 마침표·물음표가 영어보다 적게 쓰이는 편이라 TokenTextSplitter 가 토큰 500 지점에서 문장을 어색하게 자를 수 있어요.

예: "ARIA 가 카페에서 한참 웃었다. 그래서" 까지만 한 청크에 들어가고 "기분이 좋아 보였다." 가 다음 청크로 가는 모습.

본격 운영에선 sentence-aware splitter 로 보강하는 단계예요. Day 16 의 본격 다룸 영역.

이 세 가지를 지금 다 풀려고 하지 마세요. 다음 시간 Day 15 에서 Naive RAG 만으로 충분한지를 익힌 다음에 한 단계씩 보강하는 흐름이 가장 자연스러워요.

다음 시간 Day 15 — 첫 만남

다음 시간엔 docker-compose 에 pgvector 컨테이너 한 줄을 추가하고, application.yml 에 임베딩 프로파일 두 묶음 (Ollama · Gemini) 을 넣어요.

그 위에 EmbeddingService · VectorStoreConfig · DocumentLoaderService · CharacterKnowledgeIngestionService 네 부품을 한 줄씩 직접 짭니다.

키워드 네 가지로 미리 잡아두면 — EmbeddingService · pgvector · TokenTextSplitter · HNSW 입니다.

마지막으로 한 줄 — 오늘 한 번 정리하고 간 가치는 "코드를 보는 눈이 빈 슬롯을 알고 있는 상태" 예요. 다음 시간 코드가 빈 슬롯 위로 자연스럽게 떨어지는 모습을 함께 익혀봅시다.


✅ 예시 답안정답 보기

오늘은 코드 변경 0 의 개념 Day 였어요. 답안도 같은 식으로 — 단위 테스트 인용 0, 손계산과 매핑 표 위주로 구성합니다.

본 예시답안은 유일한 정답이 아니에요. 모범 사례 중 하나로 두고, 본인이 작성한 산출물과 비교해 어느 쪽이 더 자기에게 와닿는지 확인하는 용도로 써주세요.


과제 1 예시답안 — 용어 카드 만들기

핵심 접근

발제는 학생이 직접 8 개 용어를 골라 카드를 만든다 였어요. 답안은 학생이 어떤 8 개를 골랐든 비교해 볼 수 있는 모범 카탈로그를 25 개 용어 전부에 대해 한 장씩 깔아둡니다.

카드 포맷은 세 줄 — 한 줄 정의 · 한 줄 비유 · Day 15·16 등장 위치. 학생이 본인 카드와 나란히 놓고 "내 비유가 더 와닿는가, 본 답안의 비유가 더 와닿는가" 를 비교하는 식으로 쓰면 좋아요.

카드 묶음 (한계 · 필요)

컨텍스트 윈도우 (Context Window)

  • 한 줄 정의: LLM 한 번 호출에 입력으로 흘려보낼 수 있는 토큰 수의 상한 (2026 년 최신 모델 1M 토큰).
  • 한 줄 비유: 책상 위에 한 번에 펼쳐놓을 수 있는 책의 페이지 수.
  • Day 15·16 등장 위치: Day 15 Step 1 의 "왜 KB 통째 박기가 깨지는가" 의 첫 번째 축.

LLM 가중치 (Model Weights)

  • 한 줄 정의: 모델이 학습 시점까지 본 데이터로 만들어진 내부 사전. 외부에서 갱신 불가.
  • 한 줄 비유: 바리스타가 학원에서 배운 추출 매뉴얼 — 입사 후엔 안 바뀌어요.
  • Day 15·16 등장 위치: Day 15 Step 1 도입부에서 RAG 가 필요한 두 번째 이유로 박힘.

KB (Knowledge Base)

  • 한 줄 정의: RAG 가 검색해 오는 외부 저장소. 마크다운·PDF·DB 등 형태 자유.
  • 한 줄 비유: 카페 카운터 옆 손님 메모 노트 — 매번 펼쳐 보고 답해요.
  • Day 15·16 등장 위치: Day 15 의 classpath:character-knowledge/*.md 디렉토리가 KB 그 자체.

외부 지식 주입

  • 한 줄 정의: LLM 가중치 밖에 있는 자료를 질문마다 검색해서 프롬프트에 끼우는 결.
  • 한 줄 비유: 시험 시간 오픈북 — 머릿속에 다 들고 있을 필요 없어요.
  • Day 15·16 등장 위치: Day 15 의 RAG 파이프라인 전체가 이 한 결의 구현.

카드 묶음 (임베딩)

임베딩 (Embedding)

  • 한 줄 정의: 문장 한 줄을 768 (또는 1024·1536) 차원의 실수 벡터로 바꾸는 변환.
  • 한 줄 비유: 문장을 768 차원 지도 위의 한 점으로 옮기는 도구.
  • Day 15·16 등장 위치: Day 15 Step 2 의 EmbeddingService.embed(text) 한 줄에 박힘.

벡터 (Vector)

  • 한 줄 정의: 길이 N 의 실수 배열. 임베딩 모델의 출력 형태이자 벡터 DB 의 저장 단위.
  • 한 줄 비유: 좌표 한 묶음 — (x, y, z, ...) 가 그냥 길어졌을 뿐.
  • Day 15·16 등장 위치: float[] embed(...) 의 반환 타입, pgvector 의 vector(768) 컬럼 값.

차원 (Dimensions)

  • 한 줄 정의: 한 벡터를 구성하는 실수의 개수. nomic-embed-text 는 768, OpenAI text-embedding-3-small 은 1536.
  • 한 줄 비유: 의미 지도의 축 개수. 축이 많을수록 미묘한 의미까지 표현해요.
  • Day 15·16 등장 위치: VectorStoreConfig.vectorStore(...).dimensions(768) 한 줄.

코사인 유사도 (Cosine Similarity)

  • 한 줄 정의: 두 벡터가 가리키는 방향의 가까움을 -1 ~ 1 사이 값으로 돌려주는 지표.
  • 한 줄 비유: 두 화살의 겨눈 방향이 얼마나 같은지. 길이는 무관.
  • Day 15·16 등장 위치: VectorStoreConfig.distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE) 빌더 한 줄.

의미 공간 (Semantic Space)

  • 한 줄 정의: 임베딩 벡터들이 놓이는 N 차원 공간. 의미가 가까운 문장은 가까운 곳에 모여요.
  • 한 줄 비유: 지도 — 단, 거리의 기준이 물리적 위치가 아니라 의미.
  • Day 15·16 등장 위치: 명시적 코드 부분은 없지만, pgvector 테이블 자체가 이 공간의 구현.

Semantic vs Lexical Similarity

  • 한 줄 정의: 의미 유사도 (semantic) vs 어휘 겹침 유사도 (lexical). RAG 는 전자.
  • 한 줄 비유: "커피" 와 "아메리카노" 가 의미상 가까운 걸 아는 결 (semantic) vs 글자만 비교하는 결 (lexical, LIKE '%커피%').
  • Day 15·16 등장 위치: Day 16 의 하이브리드 검색 (semantic + lexical 결합) 단계에서 다시 만남.

카드 묶음 (벡터 DB)

ANN (Approximate Nearest Neighbor)

  • 한 줄 정의: 정확한 1 등 대신 높은 확률로 진짜 1 등 근처의 결과를 빠르게 돌려주는 검색 방식.
  • 한 줄 비유: 도서관 사서가 분류표로 대략 위치만 찾고 한 칸 옆 책을 건네줘도 충분한 방식.
  • Day 15·16 등장 위치: PgVectorStore.builder.indexType(HNSW) 가 ANN 인덱스 활성화.

HNSW (Hierarchical Navigable Small World)

  • 한 줄 정의: 벡터들을 계층적 그래프로 구성해 위층에서 큰 점프 → 아래층에서 미세 조정하는 ANN 인덱스.
  • 한 줄 비유: 도서관의 분류표 → 서가 → 책장 3 단 내려가기.
  • Day 15·16 등장 위치: VectorStoreConfig.indexType(PgVectorStore.PgIndexType.HNSW) — 학습 디폴트.

IVFFlat (Inverted File with Flat compression)

  • 한 줄 정의: 벡터를 N 개 클러스터로 분할 → 질문 벡터와 가까운 클러스터만 탐색하는 ANN 인덱스.
  • 한 줄 비유: 서점을 장르별 구역으로 나눠 손님 취향 장르 구역만 둘러보는 결.
  • Day 15·16 등장 위치: PgVectorStore.PgIndexType.IVFFLAT — 수백만+ 청크 대규모 운영 환경.

Top-K

  • 한 줄 정의: 벡터 DB 검색 결과 중 가장 가까운 K 개의 청크만 꺼내는 결. 보통 K=3·5·10.
  • 한 줄 비유: 검색 결과 페이지 1 의 상위 N 건만 보는 결.
  • Day 15·16 등장 위치: Day 16 의 QuestionAnswerAdvisor / RetrievalAugmentationAdvisor.topK(5) 빌더.

카드 묶음 (R·A·G)

Retrieval (검색)

  • 한 줄 정의: 질문을 임베딩해 벡터 DB 에서 Top-K 청크를 꺼내는 단계.
  • 한 줄 비유: 사서가 분류표로 책 K 권을 골라오는 동작.
  • Day 15·16 등장 위치: Day 15 Step 7 의 vectorStore.similaritySearch(query) 호출.

Augmented (증강)

  • 한 줄 정의: 꺼낸 청크를 LLM 호출의 시스템 프롬프트에 추가 컨텍스트로 끼우는 단계.
  • 한 줄 비유: 사서가 책 K 권을 책상 위에 펼쳐 놓는 동작.
  • Day 15·16 등장 위치: Day 16 advisor 가 자동으로 "다음 문서를 참고해 답해줘: [청크]" 프롬프트를 빌드.

Generation (생성)

  • 한 줄 정의: 청크가 끼워진 프롬프트로 LLM 이 답을 만드는 단계.
  • 한 줄 비유: 사서가 펼친 책 K 권을 종합해 답하는 모습.
  • Day 15·16 등장 위치: Day 16 의 ChatClient.call() 한 줄 — advisor 가 R·A 를 끝낸 뒤 모델 호출.

Naive RAG

  • 한 줄 정의: 검색 → 증강 → 생성을 직선 한 번으로 도는 가장 단순한 RAG 구성.
  • 한 줄 비유: 한 번 펼친 책 K 권으로 그대로 답하는 사서.
  • Day 15·16 등장 위치: Day 15·16 전반의 첫 그림. Naive 가 어디까지 충분한지 익히는 단계.

Modular RAG

  • 한 줄 정의: 검색 단계를 질문 재작성 · 재랭킹 · 압축 같은 모듈로 분해해 advisor 체인으로 끼우는 방식.
  • 한 줄 비유: 사서가 한 번 더 "이 K 권 중에서 진짜 가까운 N 권만" 다시 추리는 결.
  • Day 15·16 등장 위치: Day 16 후반 — Naive 의 한계 신호가 보일 때 모듈 한 개씩 끼움.

카드 묶음 (청킹)

청크 (Chunk)

  • 한 줄 정의: 큰 문서를 의미 단위로 자른 작은 텍스트 조각. 임베딩 · 검색 · 적재의 기본 단위.
  • 한 줄 비유: 두꺼운 책의 한 페이지. 검색은 페이지 단위로.
  • Day 15·16 등장 위치: DocumentLoaderService.loadAndChunk() 가 반환하는 List<Document> 의 한 원소.

오버랩 (Overlap)

  • 한 줄 정의: 청크 간 겹치는 토큰 수. 청크 경계에 걸친 문장이 두 청크 모두에 살아남게 하는 결.
  • 한 줄 비유: 책 페이지 끝에 "다음 페이지에 이어집니다" 의 짧은 미리보기를 끼우는 결.
  • Day 15·16 등장 위치: Spring AI 1.1.x 의 TokenTextSplitter 5 인자 중 두 번째 — 단 실제 이름은 minChunkSizeChars (Day 16 Step 3 에서 정정).

토큰 (Token)

  • 한 줄 정의: LLM 의 입력 단위. 영어는 단어 + 어미 단위, 한국어는 서브워드 단위.
  • 한 줄 비유: LLM 이 글을 읽는 최소 한 입 — 사람의 음절 단위와 비슷한 결.
  • Day 15·16 등장 위치: TokenTextSplitterchunkSize=500 이 500 토큰의 의미.

TokenTextSplitter

  • 한 줄 정의: Spring AI 의 표준 청킹 도구. 토큰 단위로 문서를 분할하고 마침표 지점에서 끊어요.
  • 한 줄 비유: 책에 책갈피를 500 토큰마다 끼우되 마침표 부분으로 살짝 미루는 도구.
  • Day 15·16 등장 위치: DocumentLoaderService.chunk(...) 안에서 KoreanTokenTextSplitter 로 확장 호출.

카드 묶음 (Day 16 복선)

DocumentReader

  • 한 줄 정의: 마크다운 · PDF · HTML 등 다양한 형식을 Document 객체로 읽어 들이는 Spring AI 인터페이스.
  • 한 줄 비유: 책 한 권을 형식 상관없이 읽어 들이는 만능 어댑터.
  • Day 15·16 등장 위치: Day 16 Step 4 의 TikaDocumentReader 가 PDF/DOCX 까지 흡수.

QuestionAnswerAdvisor

  • 한 줄 정의: 질문 임베딩 → 검색 → 청크 끼우기 → 생성을 한 묶음으로 자동화하는 가벼운 advisor.
  • 한 줄 비유: 사서·서가·책상이 한 직원에 압축된 모양.
  • Day 15·16 등장 위치: Day 16 의 RAG 첫 advisor 등장 시점.

RetrievalAugmentationAdvisor

  • 한 줄 정의: QuestionAnswerAdvisor 의 본격 버전. 쿼리 변환·재랭킹·메타데이터 필터 등 모듈을 끼울 수 있어요.
  • 한 줄 비유: 베테랑 사서 — 손님 질문을 한 번 더 풀어 듣고 책을 고르는 결.
  • Day 15·16 등장 위치: Day 16 후반 — Modular RAG 의 첫 모듈을 얹는 단계.

metadata

  • 한 줄 정의: 청크에 같이 박히는 부가 정보 — character_id, source 파일명 등. 검색 시 필터로 활용.
  • 한 줄 비유: 책에 붙은 분류 라벨 + 대출 카드.
  • Day 15·16 등장 위치: CharacterKnowledgeIngestionService.attachCharacterIdMetadata(chunk) — 청크에 character_id 넣는 한 줄.

리랭킹 (Re-ranking)

  • 한 줄 정의: Top-K 검색 결과를 질문-청크 한 쌍씩 다시 점수 매겨 진짜 가까운 N 개만 남기는 모듈.
  • 한 줄 비유: 사서가 추린 책 5 권을 읽어 보고 진짜 답 가까운 3 권만 골라내는 결.
  • Day 15·16 등장 위치: Day 16 후반의 Modular RAG 첫 모듈.

채점 포인트

포인트 설명 배점 가중
5 분류 균형 한계·임베딩·벡터DB·R·A·G·청킹 5 분류에서 최소 1 개씩 포함
비유의 독창성 강의 비유 그대로 복붙 안 하고 본인 식으로 풀어쓴 부분
Day 15·16 매핑 정확성 등장 위치를 클래스명 · 메서드명 · Step 번호 단위로 적은 흐름
한 줄 정의의 정확성 차원·코사인 유사도 같은 용어의 수학적 의미가 흐려지지 않았는가
카드 8 장의 컴팩트함 한 카드가 3~4 줄 이내로 정리됨 (장문 설명은 감점)

흔한 실수

  • 5 분류 중 1·2 개 분류에 8 장 몰아넣기 → 본인이 편한 결만 정리한 모습.

    5 분류 균형 룰의 의도가 깨짐. 어색해도 한 장씩 깔아야 면접에서 어느 카테고리든 답할 수 있는 어휘가 만들어져요.

  • 강의 비유를 그대로 복붙 → 비유의 감각은 내 말로 풀어쓸 때 살아나요.

    "바리스타의 손님 메모" 가 강의 비유라면 본인은 "내비게이션 즐겨찾기" · "단골 약국의 처방 기록" 같이 자기 도메인으로 옮겨요.

  • Day 15·16 등장 위치를 "Day 15 어딘가" 로 뭉뚱그림 → 구체적 클래스명/Step 번호가 빠지면 다음 시간 코드에서 눈이 찾아갈 단서가 흐려져요.

    본 답안처럼 EmbeddingService.embed(text) 한 줄까지 적는 흐름.

  • 차원·코사인 유사도의 수학적 의미를 그냥 거리로 흐림 → 면접에서 "왜 코사인 거리가 표준인가요?" 가 들어오면 답이 나오지 않아요.

    방향의 가까움 vs 유클리드 거리 (절대 위치) 의 차이까지 한 줄 적어두는 흐름.

실무 개선 포인트 (심화)

  • 운영 환경에선 용어 사전을 위키 한 페이지로 정리하는 흐름 — 팀이 RAG 시스템을 운영하다 보면 임베딩 모델 버전 · 차원 수 · 인덱스 종류 같은 결정이 산만하게 흩어져요.

    사내 위키에 RAG 용어 사전 한 페이지를 두고 각 결정의 왜 그 선택이었는지까지 한 줄 적어두면 신입 합류 시간이 절반으로 줄어요.

  • 본인 도메인 어휘로 비유 사전을 만들어두면 면접에서 차별화 — 강의 비유 (바리스타·도서관 사서) 는 보편적이라 면접관도 자주 들어요.

    본인 백그라운드 (게임·법률·의료 등) 의 어휘로 비유 사전을 만들어두면 "이 분은 RAG 를 자기 도메인으로 풀어 본 사람이구나" 의 신호가 됩니다.

🎯 면접관을 홀리는 핵심 멘트

"RAG 의 본질은 모델 외부에 사전을 두고 질문마다 필요한 청크 N 개만 컨텍스트에 흘리는 흐름입니다. 비용·속도·갱신성 세 축을 한 번에 푸는 방식이라 Fine-tuning 의 대안으로 정착했어요. 임베딩이 문장을 의미 공간의 한 점으로 매핑하고, ANN 인덱스가 그 점들 사이 Top-K 를 빠르게 찾아주는 두 부품이 모든 흐름의 핵심입니다."


과제 2 예시답안 — 청크 크기 시뮬레이션

핵심 접근

발제는 가상의 5,000 토큰 KB 를 (a) 200/20 · (b) 500/50 · (c) 1,000/100 세 가지로 자른다고 가정하고 손계산 표 한 장.

답안은 세 시나리오의 청크 수를 동일한 식으로 계산하고, Top-K=5 토큰 비용 + 질문 한 번당 총 토큰 + 장점·단점을 한 표에 정리해 트레이드오프가 한눈에 보이게 합니다.

마지막에 본인 결론 한 단락으로 강의 디폴트 500/50 의 근거를 감각적으로 익히는 흐름.

청크 개수 계산식

청크 개수 공식은 한 줄.

청크 개수 = ceil( (총 토큰 - overlap) / (청크 크기 - overlap) )
  • 총 토큰 - overlap — 첫 청크 이후 진짜 새로 잡히는 토큰 수의 분자
  • 청크 크기 - overlap — 한 청크가 겹치지 않고 새로 잡는 토큰 수 (effective stride)

본 식은 대략적 추정이에요. 실제로는 마침표 지점 보정 + minChunkSizeChars 룰 (Spring AI 의 실제 인자명) 때문에 ±10% 정도 흔들려요. 손계산은 추정만 잡고, 정확값은 Day 15 코드 돌려서 로그로 확인.

세 시나리오 청크 개수 계산

KB 총 토큰 = 5,000 으로 고정.

(a) chunk=200 · overlap=20

청크 개수 = ceil( (5,000 - 20) / (200 - 20) )
        = ceil( 4,980 / 180 )
        = ceil( 27.67 )
        = 28 개

(b) chunk=500 · overlap=50 (학습 디폴트)

청크 개수 = ceil( (5,000 - 50) / (500 - 50) )
        = ceil( 4,950 / 450 )
        = ceil( 11.00 )
        = 11 개

(c) chunk=1,000 · overlap=100

청크 개수 = ceil( (5,000 - 100) / (1,000 - 100) )
        = ceil( 4,900 / 900 )
        = ceil( 5.44 )
        = 6 개

청크 크기가 5 배 (200 → 1,000) 늘면 청크 개수는 약 1/5 (28 → 6) 로 줄어요. 직관과 일치.

트레이드오프 표 한 장

같은 질문 "ARIA 의 성격을 알려줘" 가 Top-K=5 로 검색된다고 가정. 시스템 프롬프트 200 토큰 + 질문 30 토큰 가정.

시나리오 청크 개수 청크 한 개 토큰 Top-K=5 청크 토큰 시스템+질문 질문당 총 토큰 의미 단위 보존 검색 정밀도 적합 영역
(a) 200/20 28 200 1,000 230 1,230 약함 (문장 중간 잘림) 좁고 정확 FAQ · 단답형
(b) 500/50 🌟 11 500 2,500 230 2,730 중간 (한 문단) 균형 일반 KB · 캐릭터 프로필
(c) 1,000/100 6 1,000 5,000 230 5,230 강함 (여러 문단) 넓고 두루뭉술 논문 · 보고서 · 코드

표에서 읽어내야 하는 흐름

  1. 토큰 비용은 청크 크기에 비례 — (a) 1,230 → (c) 5,230 으로 약 4.3 배. 청크 크기가 5 배 늘면 비용도 약 5 배.
  2. 검색 정밀도는 청크 크기에 반비례 — 작은 청크는 질문 한 줄과 같은 의미 영역만 좁게 잡고, 큰 청크는 한 의미 단위가 통째로 들어와 노이즈도 함께 끌려와요.
  3. 의미 단위 보존은 청크 크기에 비례 — 200 토큰이면 한 문장도 끝나기 전에 잘릴 수 있고, 1,000 토큰이면 한 문단~한 문단 반이 통째 들어가요.

세 축이 동시에 만족되는 지점이 없어요. 운영자는 KB 의 의미 단위와 질문 길이에 맞춰 디폴트를 잡아요.

💡 튜터의 결론

청크 크기는 의미 단위 vs 비용·정밀도의 트레이드오프예요. 학습 디폴트 500/50 은 한국어 한 문단 (~400~600 토큰) 의 의미 단위를 살리면서, Top-3 비용이 1,500 토큰 수준에 머무는 균형점입니다.

실무에선 KB 의 의미 단위에 따라 갈려요.

  • 블로그 글 · 가이드 문서 (한 글 ~ 800~1,500 토큰) → chunk=800~1,000
  • 코드 주석 + 함수 시그니처 (의미 단위가 짧음) → chunk=200~300
  • 일기 · 캐릭터 프로필 · 회의록 (한 문단 단위) → chunk=500 (디폴트)
  • 논문 · 법률 조문 (긴 문맥 필요) → chunk=1,000~1,500

본인 KB 가 어느 쪽 자료인지 한 줄로 정리한 다음 디폴트 청크 크기를 정하면, 첫 운영에서 큰 사고가 안 일어나요.

채점 포인트

포인트 설명 배점 가중
청크 개수 계산식 ceil((총-overlap)/(chunk-overlap)) 한 줄이 들어감
세 시나리오 청크 수 추정 28 · 11 · 6 (±2 허용) — 직관적 차이 보임
토큰 비용 한 표에 정리 Top-K=5 청크 토큰 + 시스템+질문 + 총 토큰 3 컬럼
장점·단점 컬럼 의미 단위 보존 vs 검색 정밀도의 반비례 관계 한 줄 적힘
본인 결론 한 단락 강의 디폴트 (500/50) 와 같든 다르든 근거 한 줄
운영 환경 예시 블로그/코드/일기/논문 등 도메인별 청크 크기 차이 인지

흔한 실수

  • 청크 개수를 "총 토큰 / 청크 크기" 로만 계산 → overlap 을 빼먹은 사례.

    (a) 시나리오에서 5,000/200 = 25 로 계산하면 실제 28 과 3 청크 차이. 오버랩이 effective stride 를 줄인다는 핵심 흐름이 빠짐.

  • 토큰 비용에 시스템 프롬프트·질문을 빼먹음 → 청크만 계산하면 1,000 · 2,500 · 5,000 으로 끝나는데, 실제 비용은 시스템 프롬프트 + 질문 + 응답 여유분까지 합쳐야 운영의 진짜 비용.

    한 번에 익혀두면 면접에서 "한 호출 비용을 어떻게 계산하시나요?" 답이 매끄러워요.

  • 장점·단점을 "작으면 좋다 / 크면 좋다" 같은 단순 비교로 정리 → 모든 트레이드오프는 축을 명시해야 해요.

    "청크가 작으면 좋다" 가 아니라 "청크가 작으면 검색 정밀도는 좋지만 의미 단위가 깨진다" 두 축으로.

  • 본인 결론을 "강의 디폴트가 정답이다" 로 마무리 → 강의 디폴트는 학습용 첫 그림이지 절대 정답이 아니에요.

    본인 KB 의 의미 단위가 코드 주석이라면 chunk=200, 논문이면 chunk=1,000 으로 갈리는 흐름을 보여줘야 운영 감각이 익혀져요.

실무 개선 포인트 (심화)

  • A/B 청크 실험 자동화 — 운영 환경에선 청크 200·500·1,000 세 가지로 동일 KB 를 세 번 적재한 다음 같은 질문 100 개의 검색 품질을 비교하는 실험을 코드로 만들어둬요.

    사용자 평가 점수 (LGTM · 5 점 척도) 까지 합치면 본인 KB 에 진짜 맞는 청크 크기가 통계로 나와요. 본 강의 범위 밖이지만 운영 6 개월 이상 가는 RAG 시스템이라면 한 번은 거쳐야 할 단계.

  • sentence-aware splitter 와의 하이브리드 — Spring AI 의 TokenTextSplitter 는 토큰 단위로 끊으면서 마침표 부분으로 살짝 미루는 방식이에요.

    한국어는 마침표가 영어보다 적게 쓰여서 어색한 곳에서 잘릴 때가 있어요. 운영에선 kss (Korean Sentence Splitter) 같은 라이브러리로 먼저 문장 단위 분할한 다음 문장을 모아 청크 크기에 맞추는 식으로 갈리기도 해요. Day 16 의 깊은 다룸 영역.

🎯 면접관을 홀리는 핵심 멘트

"청크 크기는 의미 단위와 비용·정밀도의 트레이드오프입니다. 작은 청크는 검색이 정밀하지만 의미가 깨지고, 큰 청크는 의미가 풍부하지만 비용이 폭발해요. 본인 KB 의 의미 단위가 한 문단이면 500 토큰, 코드 주석이면 200 토큰, 논문이면 1,000 토큰이 디폴트입니다. 한 번 박은 디폴트는 운영 데이터로 A/B 실험해 보정해 갑니다."


과제 3 예시답안 — Day 15·16 코드 미리 읽기 + 5 개념 매핑

핵심 접근

발제는 EmbeddingService.java · VectorStoreConfig.java · CharacterKnowledgeIngestionService.java 세 파일을 코드는 모른 채로 클래스/메서드 시그니처만 읽고, 오늘 익힌 5 개념을 어느 메서드와 매핑하는지 표 한 장.

답안은 실제 코드베이스의 시그니처를 그대로 인용하고, 5 개념을 각 메서드에 매핑합니다. 학생이 본인 답안과 비교해 어떤 매핑이 본질에 가까웠는지 확인하는 흐름.

세 파일의 실제 시그니처 (코드베이스 인용)

EmbeddingService.java

// kr.spartaclub.aifriends.rag.service.EmbeddingService
// (전체 코드: lectures/spring-ai/.../rag/service/EmbeddingService.java)

@Service
@RequiredArgsConstructor
public class EmbeddingService {

    private final EmbeddingModel embeddingModel;

    public float[] embed(String text) { ... }

    public int dimension() { ... }
}

VectorStoreConfig.java

// kr.spartaclub.aifriends.rag.config.VectorStoreConfig
// (전체 코드: lectures/spring-ai/.../rag/config/VectorStoreConfig.java)

@Configuration
public class VectorStoreConfig {

    public static final String CHARACTER_KNOWLEDGE_TABLE = "character_knowledge";

    @Bean
    @ConfigurationProperties("spring.ai-vectorstore-datasource")
    public DataSourceProperties vectorStoreDataSourceProperties() { ... }

    @Bean(name = "vectorStoreDataSource")
    public DataSource vectorStoreDataSource() { ... }

    @Bean(name = "vectorStoreJdbcTemplate")
    public JdbcTemplate vectorStoreJdbcTemplate(DataSource vectorStoreDataSource) { ... }

    @Bean
    public VectorStore vectorStore(JdbcTemplate vectorStoreJdbcTemplate, EmbeddingModel embeddingModel) { ... }
}

CharacterKnowledgeIngestionService.java

// kr.spartaclub.aifriends.rag.service.CharacterKnowledgeIngestionService
// (전체 코드: lectures/spring-ai/.../rag/service/CharacterKnowledgeIngestionService.java)

@Slf4j
@Service
@RequiredArgsConstructor
public class CharacterKnowledgeIngestionService {

    private final DocumentLoaderService documentLoaderService;
    private final VectorStore vectorStore;
    private final JdbcTemplate vectorStoreJdbcTemplate;

    public int ingest() { ... }

    private void attachCharacterIdMetadata(Document chunk) { ... }

    static String resolveCharacterId(String sourceFilename) { ... }

    public void reset() { ... }

    public long count() { ... }
}

5 개념 매핑 표 한 장

오늘 익힌 개념 Day 15·16 클래스 메서드/필드 시그니처 한 줄 역할
임베딩 EmbeddingService embed(String text): float[] 문장 → 768 차원 벡터 변환 게이트웨이
차원 (dimensions) EmbeddingService + VectorStoreConfig dimension(): int + .dimensions(768) 빌더 활성 프로바이더의 출력 차원 — pgvector 컬럼 정의값
벡터 DB VectorStoreConfig vectorStore(JdbcTemplate, EmbeddingModel): VectorStore pgvector 빈 손등록 — 자동 설정에 양보받음
ANN 인덱스 (HNSW) VectorStoreConfig .indexType(PgVectorStore.PgIndexType.HNSW) 빌더 HNSW 인덱스 활성화 — 검색 빠름 / 적재 약간 느림
코사인 유사도 VectorStoreConfig .distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE) 빌더 두 벡터 방향의 가까움으로 거리 계산
청킹 DocumentLoaderService (과제 범위 밖) loadAndChunk(): List<Document> KoreanTokenTextSplitter 호출 — chunk 500 / 한국어 마침표 마크
KB 적재 (Retrieval 의 전제) CharacterKnowledgeIngestionService ingest(): int 청크들을 vectorStore.add(chunks) 한 줄로 일괄 적재
metadata CharacterKnowledgeIngestionService attachCharacterIdMetadata(Document) 청크에 character_id 박기 — 캐릭터별 KB 분리의 핵심 키
KB 초기화 (운영 안전성) CharacterKnowledgeIngestionService reset(): void, count(): long 적재된 청크 비우기 + 현재 청크 수 조회

시그니처만 보고 추론할 수 있는 부분들

추론 1 — EmbeddingService 가 3 메서드뿐인 이유

embed(text) + dimension() 두 개의 얇은 게이트웨이 메서드만 노출. EmbeddingModel 인터페이스 한 줄로 주입받아 프로바이더 (Ollama · Gemini) 가 바뀌어도 본 클래스는 0 줄 수정.

본 강의 Day 2 부터 정착된 프로바이더 추상화 원칙이 RAG 영역에도 그대로 흐르는 모습이에요. OllamaEmbeddingModel · VertexAiGeminiEmbeddingModel 같은 구현체 타입을 직접 받지 않고 인터페이스로만 받는 방식.

추론 2 — VectorStoreConfig 가 빈 4 개를 손등록하는 이유

vectorStoreDataSourcePropertiesvectorStoreDataSourcevectorStoreJdbcTemplatevectorStore 의 4 단 빈 체인이 들어가요.

클래스 javadoc 에 "본 앱의 기본 DataSource 는 MySQL 이라, pgvector 자동 설정에 맡기면 폭발" 의 단서가 적혀 있어요.

기본 DataSource 와 별도로 pgvector 전용 DataSource 를 하나 더 두고, 그 위에서만 도는 PgVectorStore 를 손등록하는 흐름. Spring AI 의 자동 설정이 @ConditionalOnMissingBean(VectorStore.class) 로 양보하니, 본 빈이 등록되면 자동 설정은 조용히 손을 뗀다는 의도.

추론 3 — CharacterKnowledgeIngestionService.ingest()int 를 반환하는 이유

적재된 청크 수를 한 줄로 돌려줘요. "몇 개를 넣었나" 가 학습용 적재기의 1 차 신호. 운영 환경에선 checksum / version 비교로 변경분만 다시 넣는 본격 적재기로 진화한다는 흐름이 javadoc 에 적혀 있어요.

reset() + count() 가 함께 있는 모습도 운영 안전성의 흐름 — 적재기는 멱등하지 않지만, 깨끗한 상태에서 다시 시작할 길은 항상 열어둔다는 사고.

추론 4 — attachCharacterIdMetadataprivate 인 이유

metadata 넣는 흐름을 ingest() 한 줄 안으로 캡슐화. 외부에서 "이 청크에만 캐릭터 ID 넣어줘" 같이 호출하면 적재기의 책임 경계가 흐려져요. static String resolveCharacterId(...) 만 패키지 가시성으로 노출해 단위 테스트에서 파일명 → 캐릭터 ID 매핑만 검증할 수 있게 둔 흐름.

💡 튜터의 결론

클래스 이름·메서드 이름이 곧 RAG 의 개념 지도예요. Day 15 의 EmbeddingServiceVectorStoreConfigDocumentLoaderServiceCharacterKnowledgeIngestionService 4 부품이 오늘 5 개념의 코드 외피인 셈이에요.

다음 시간 코드 한 줄 한 줄이 오늘 익힌 개념과 1:1 로 만나요. "이건 임베딩이네" · "이건 벡터 DB 빈 등록이네" · "이건 청크에 metadata 넣는 부분이네" 의 매핑이 눈에 먼저 찾아가는 상태로 다음 시간을 시작할 수 있어요.

채점 포인트

포인트 설명 배점 가중
시그니처 정확성 embed(String): float[] · vectorStore(JdbcTemplate, EmbeddingModel): VectorStore 같은 반환 타입까지 적힘
5 개념 매핑의 1:1 흐름 임베딩·벡터 DB·ANN·청킹·metadata 가 각각 클래스+메서드 한 줄로 매핑됨
청크 부분 솔직 표기 청킹이 DocumentLoaderService 의 책임이라 과제 범위 밖으로 솔직히 표기
시그니처 추론 한 줄 "왜 메서드가 이 모양인지" 의 추론이 한 두 줄이라도 있는가
학습 의도 (Day 15·16 가시화) 다음 시간 "눈이 먼저 찾아갈 곳" 의 감각이 답안에 담겼는가

흔한 실수

  • 메서드 본문을 깊게 읽고 답안에 적음 — 발제가 시그니처만 5 초 이내였어요.

    본문을 깊게 보면 코드 독해의 새로운 방식 (개념→코드의 역방향) 의 학습 의도가 흐려져요. 본문은 다음 시간 Day 15 에서 익힙니다.

  • 5 개념을 한 클래스에 다 매핑함 — 임베딩·벡터 DB·청킹·metadata 가 모두 EmbeddingService 에 있다 같은 답은 책임을 못 잡은 모습.

    각 개념의 책임 영역이 다른 클래스에 분산돼 있어요. 책임 경계의 감각이 잡혀야 답안의 의도가 살아나요.

  • DocumentLoaderService 가 과제 범위 밖이라는 걸 놓치고 "청킹이 어디 있지?" 헤맴 → 발제가 세 파일만 줬어요.

    청킹은 Day 15 의 다른 파일로 솔직히 표기하면 OK. 답안에 없는 것을 지어내는 사고가 면접에서 가장 큰 위험이에요.

  • 시그니처를 본인 말로 풀어 쓰지 않음 — 실제 코드 embed(String text): float[] 만 적으면 코드를 베껴 적은 거지 추론한 게 아니에요.

    "문장을 768 차원 벡터로 바꾸는 게이트웨이" 같이 시그니처로 추론한 한 줄 역할이 옆에 적혀야 학습 의도가 살아나요.

실무 개선 포인트 (심화)

  • 새 라이브러리 학습 시 공식 문서보다 시그니처를 먼저 보는 흐름 — 시니어가 새 SDK 를 빠르게 익히는 핵심 방식이에요.

    공식 문서는 어떻게 쓰는가를 설명하지만, 시그니처는 무엇을 받고 무엇을 돌려주는가의 본질을 한 줄로 보여줘요. 본인 IDE 의 Structure / Outline 패널을 켜고 메서드 시그니처만 먼저 훑어 이 라이브러리의 책임 경계를 잡는 방식을 한 번 익혀두면 새 기술 학습 시간이 절반으로 줄어요.

  • Spring AI 의 EmbeddingModel / VectorStore 인터페이스를 직접 열어보기 — 본 강의 범위는 사용하는 쪽이지만, 한 번은 IDE 의 Go to Definition 으로 인터페이스 자체를 열어보길 권해요.

    embed(String): float[] 만 보이는 메서드 옆에 batch embed · list embed 같은 오버로드가 들어 있어, 운영에서 토큰 비용 줄이는 방식 (배치 임베딩) 의 단서가 시그니처에 살아 있어요.

🎯 면접관을 홀리는 핵심 멘트

"시그니처만으로 라이브러리의 책임 경계를 잡습니다. EmbeddingService.embed(String): float[] 한 줄이 임베딩의 본질을 압축하고, VectorStoreConfig.vectorStore(...): VectorStore 가 ANN 인덱스의 외피라는 걸 메서드 시그니처에서 바로 읽어내요. 새 라이브러리를 익힐 때 공식 문서보다 시그니처를 먼저 보는 방식이 시니어 개발자의 학습 속도를 끌어올리는 비법입니다."


생각해볼 주제 1 예시답안 — RAG vs Fine-tuning

문제 상황 요약

LLM 에 우리 회사 도메인 지식을 넣는 두 가지 방식 — Fine-tuning (모델 가중치 자체를 학습으로 갱신) 과 RAG (외부 사전을 검색해 프롬프트에 끼우기).

학생이 마주하는 질문은 "같은 도메인 지식을 주입할 때 어느 쪽을 골라야 하는가, 두 가지를 같이 쓰는 시나리오가 있는가".

튜터의 가이드 및 해설

먼저 두 방식의 본질 차이를 한 줄로 잡고 시작할게요.

  • Fine-tuning — 모델의 가중치를 학습으로 갱신. 도메인 어휘·톤·스타일이 모델 안에 영구히 들어가요. 한 번 학습하면 매 호출마다 자동으로 살아나는 방식.
  • RAG — 모델의 가중치는 그대로 두고, 외부 사전을 검색해서 프롬프트에 끼움. 매 호출마다 그 질문에 필요한 청크만 흘러가는 방식.

두 방식을 4 축으로 비교하면 운영의 결정이 보여요.

RAG Fine-tuning
비용 임베딩 + 벡터 DB 운영비 (월 수십~수백만 원 수준) GPU 학습 비용 (수백~수천만 원 1 회 + 추론 시 약간)
갱신 KB 한 줄 추가 = 즉시 반영 (운영 중) 새 데이터 = 재학습 사이클 (수일~수주)
정확도 청크 품질에 의존 + 명시적 출처 (citation) 가능 모델 가중치에 내재화돼 답의 근거 추적 어려움
운영 복잡도 벡터 DB · 임베딩 파이프라인 추가 학습 데이터 관리 · GPU · MLOps 인프라 추가

두 가지 운영 시나리오

Option A — RAG 먼저, 한계 보이면 Fine-tuning 추가

대부분의 실무 현장에서 RAG 가 먼저예요. 갱신성·비용·운영 복잡도 3 축이 모두 유리해서요. 한 줄의 KB 변경이 재학습 없이 운영에 반영되는 흐름은 운영 친화적입니다.

장점 — 빠른 도입 + 운영 중 갱신 + 답의 출처 추적 가능. 단점 — 검색 품질이 청크와 임베딩 모델에 의존. 도메인 어휘가 일반어와 너무 다르면 임베딩이 잡아내기 어려움.

Option B — Fine-tuning 으로 도메인 어휘·톤을 가중치에 새기기

(1) 도메인 어휘가 일반어와 너무 다른 영역 (의료·법률·코드베이스 도메인) 과 (2) 톤·스타일을 매 호출마다 시스템 프롬프트로 강제하기엔 비용이 폭발하는 영역에 한정.

장점 — 매 호출마다 시스템 프롬프트 절약 + 도메인 어휘의 정확한 매칭 + 일관된 톤. 단점 — 새 정보 반영 시 재학습 + GPU 비용 + 답의 근거 추적 어려움.

현업에서는 보통

RAG 가 먼저, Fine-tuning 은 한정된 영역에만. 두 가지를 같이 쓰는 하이브리드도 자주 등장해요.

  • 도메인 어휘를 가중치에 새기는 가벼운 Fine-tuning (LoRA · QLoRA — 수십만 원 수준) + RAG 로 사실 자료 주입. 두 방식의 강점을 합치는 모습.
  • ai-friends 라면 — 캐릭터의 톤·말투 (ARIA 는 차분 / HARU 는 활발) 를 Fine-tuning 으로 가중치에 새기고, 마스터 일기 · 세계관 설정집은 RAG 로 검색하는 식이 잘 맞아요. 톤은 자주 안 바뀌지만 일기는 매일 자라요.

다만 본 강의에선 Fine-tuning 은 다루지 않아요. RAG 만으로도 충분히 강한 시스템이 만들어집니다.

🎯 면접관을 홀리는 핵심 멘트

"RAG 가 먼저, Fine-tuning 은 한정된 영역만 — 갱신성과 운영 복잡도에서 RAG 가 압도적이라 대부분의 도메인 지식 주입은 RAG 로 풀립니다. Fine-tuning 은 도메인 어휘가 일반어와 크게 다르거나 톤·스타일을 가중치에 새겨야 할 때만 보조로 끼우고, 새 정보 반영은 RAG 가 책임지는 하이브리드가 2026 년 표준입니다."


생각해볼 주제 2 예시답안 — 임베딩 차원 384 vs 1536

문제 상황 요약

임베딩 모델은 출력 차원이 모델마다 달라요. 작은 모델은 384, Ollama 의 nomic-embed-text 는 768, OpenAI 의 text-embedding-3-small 은 1536.

차원이 크면 의미를 풍부하게 담지만 저장 비용·검색 속도가 자라요. 학생이 마주하는 질문은 "같은 KB 를 (a) 384 / (b) 768 / (c) 1536 차원으로 적재했을 때 트레이드오프가 어떻게 갈리는가, 학습과 운영의 디폴트는 왜 다른가".

튜터의 가이드 및 해설

차원 수의 본질을 먼저 잡고 가요. 차원이 N 인 벡터 한 개는 float 4 byte × N 의 메모리를 써요. 384 차원이면 1.5KB, 1536 차원이면 6KB. 청크 1 백만 개라면 차원에 따라 1.5GB ~ 6GB 의 저장 차이가 발생.

검색 속도도 벡터 한 번 비교의 연산량이 N 에 비례해요. 384 vs 1536 은 4 배 차이. HNSW · IVFFlat 같은 ANN 인덱스가 log(N) 으로 줄여주지만, 기본 단가의 4 배는 그대로 살아 있어요.

세 축으로 비교 표를 정리해 둘게요.

384 차원 768 차원 (디폴트) 1536 차원
저장 비용 1× (기준)
검색 속도 1× (가장 빠름) 0.5× (절반 속도) 0.25× (1/4 속도)
의미 정확도 미묘한 의미 차이 못 잡음 일반 운영 충분 도메인 특화 + 미묘한 결까지
적합 영역 PoC · 학습 · 단순 매칭 일반 운영 KB 정확도 SLA 가 있는 운영

두 가지 운영 시나리오

Option A — 학습/PoC 단계에서 384 차원

새 RAG 프로젝트의 PoC · 학습 단계에서는 384 차원이 충분해요. 검색 결과가 "진짜 가까운 청크가 Top-3 에 들어오는가" 만 확인하는 단계라, 의미의 미묘한 차이까지 잡을 필요는 없어요.

장점 — 저장·검색·임베딩 호출 비용 모두 가장 작음. 빠른 반복이 가능. 단점 — 도메인 어휘 매칭 정밀도가 약함. 운영으로 가져가면 검색 품질이 흔들려요.

Option B — 운영 단계에서 768~1536 차원

운영 환경은 정확도 SLA (예: 사용자가 만족하는 답이 90% 이상) 가 걸려 있어요. 여기선 768~1536 차원이 디폴트.

장점 — 의미의 미묘한 부분까지 잡아 검색 정밀도 ↑. 도메인 특화 어휘에서 강함. 단점 — 저장·검색 비용 자라요. 차원이 2 배 늘면 비용도 약 2 배.

현업에서는 보통

PoC = 384 → 운영 = 768~1536 의 단계적 흐름. 본 강의 ai-friends 는 768 차원 (nomic-embed-text · text-embedding-004 양쪽 모두 768) 으로 통일해 학습과 운영의 갭을 줄이는 방식을 적용했어요.

차원이 다른 모델 간 호환 불가도 한 줄 짚어둘게요. 384 차원 KB 를 적재해 두고 운영 중에 1536 차원 모델로 갈아 끼면 벡터 공간의 좌표계가 완전히 달라요. 전체 KB 재임베딩이 동반돼야 해요. 운영에서 모델 업그레이드가 간단한 일이 아닌 이유가 여기서 드러나요.

운영 6 개월 이상 갈 시스템이라면 차원을 처음부터 768 이상으로 잡아 모델 업그레이드 여지를 두는 흐름이 안전해요. 384 의 저장·검색 비용 절감은 단기적 매력일 뿐.

🎯 면접관을 홀리는 핵심 멘트

"차원이 2 배 늘면 저장·검색 비용도 약 2 배 자라지만, 의미의 미묘한 부분까지 잡아 정확도가 한 단계 올라갑니다. PoC 는 384 로 시작해 운영은 768~1536 으로 가는 단계적 흐름이 표준입니다. 단 한 가지 — 운영 중 차원을 바꾸면 전체 KB 재임베딩이 동반된다는 사실 때문에, 처음 디폴트를 잡을 때 향후 6 개월 이상의 운영을 가정해야 해요."


생각해볼 주제 3 예시답안 — 청크 크기 결정의 본질

문제 상황 요약

청크 크기 (chunk size) 를 정할 때 두 가지 시각이 있어요. "질문의 평균 길이 (50 토큰) 에 맞추는 게 검색 매칭 정확도에 좋다" 와 "KB 한 문서의 의미 단위 (한 문단 = 약 300 토큰) 에 맞추는 게 응답 품질에 좋다".

ai-friends 의 캐릭터 KB 처럼 마크다운 한 문단 = 한 의미 단위인 자료에서 (A) 질문 평균 길이 기준 chunk=100~200 과 (B) KB 의미 단위 기준 chunk=300~500 중 어느 쪽을 디폴트로 둘지.

튜터의 가이드 및 해설

두 시각의 본질 차이를 먼저 잡고 가요.

  • (A) 질문 길이 기준 — 질문이 50 토큰이면 청크도 50~200 토큰 정도로 맞춰 질문-청크 토큰 분포의 유사성으로 임베딩 매칭 정밀도를 높이려는 시각.

    임베딩 모델이 비슷한 분포의 벡터를 더 잘 매칭한다는 근거.

  • (B) KB 의미 단위 기준 — KB 의 한 의미 단위가 통째로 한 청크 안에 들어가야 답이 정확하다는 시각.

    한 문단이 분리되면 "ARIA 는 INTJ 라서" / "분석적이다" 같이 의미가 깨져요.

두 시각을 4 축으로 비교하면 결정의 본질이 보여요.

(A) 질문 길이 기준 (chunk=100~200) (B) 의미 단위 기준 (chunk=300~500)
검색 매칭 정밀도 높음 (분포 일치) 중간 (분포 다름)
의미 단위 보존 약함 (문단 중간 잘림) 강함 (한 문단 통째)
LLM 의 답 품질 청크가 좁아 추론에 단서 부족 청크에 충분한 문맥
Top-K 토큰 비용 작음 (한 청크 작음) 중간 (한 청크 큼)

두 시각을 더 깊게

(A) 가 더 결정적인 영역 — FAQ · 단답형 검색.

질문 "환불 정책이 뭐예요?" 가 정답 한 문장 "환불은 구매 후 7 일 이내 가능합니다" 와 매칭되는 상황이에요. 답이 한 문장에 들어 있고 추론이 거의 필요 없어요. 청크가 좁아도 충분.

(B) 가 더 결정적인 영역 — 추론형 답변 · 캐릭터 톤 응답.

질문 "ARIA 의 성격을 알려줘" 에 답하려면 "ARIA 는 INTJ 라서 분석적이고 차분하다" 같이 문장의 두 부분이 한 의미 단위로 들어가야 해요.

(A) 의 chunk=100 으로 자르면 "ARIA 는 INTJ 라서" 만 한 청크에 들어가고 "분석적이고 차분하다" 가 다음 청크로 갈려서 답이 깨져요.

현업에서는 보통

(B) 의미 단위가 더 결정적. 그 의미가 깨지면 검색 매칭이 아무리 정확해도 LLM 이 답을 못 만들어요.

ai-friends 의 KB 자료 (캐릭터 프로필 · 세계관 설정 · 마스터 일기) 는 모두 한 의미 단위가 한 문단이라 chunk=500 디폴트가 자연스러워요. 강의 디폴트가 거기에 정착한 이유.

다만 질문 길이는 학생/사용자가 통제 가능한 변수 (UX 설계) 라는 점도 한 줄 짚어둘게요. 질문이 너무 짧으면 "질문 재작성 (query rewriting)" 모듈로 질문을 풀어 검색에 도움 주는 흐름이 Modular RAG 의 첫 단계예요. 질문 길이를 청크에 맞추는 게 아니라 청크는 의미 단위로, 질문은 재작성으로 두 변수를 분리해 보정하는 방식.

KB 의 의미 단위는 도메인의 본질적 구조라 바꿀 수 없고, 질문 길이는 바꿀 수 있는 변수. 운영의 디폴트는 바꿀 수 없는 쪽을 기준으로 잡아요.

🎯 면접관을 홀리는 핵심 멘트

"의미 단위가 더 결정적입니다. 의미 단위가 깨진 청크는 검색이 아무리 정확해도 LLM 이 답을 만들 수 없어요. 질문 길이는 학생이 통제할 수 있는 UX 변수 (질문 재작성 모듈로 보정 가능) 이지만 KB 의 의미 단위는 도메인의 본질적 구조라 바꿀 수 없어요. 운영의 디폴트는 항상 바꿀 수 없는 변수를 기준으로 잡습니다 — 캐릭터 프로필이면 한 문단 500 토큰, 코드 주석이면 200 토큰, 논문이면 1,500 토큰."


이번 Day 의 답안은 코드 변경 0 의 흐름으로, 손계산 · 매핑 표 · 비교 표 위주로 구성했어요.

다음 시간 Day 15 에서는 오늘 익힌 5 개념 위에 EmbeddingService · VectorStoreConfig · DocumentLoaderService · CharacterKnowledgeIngestionService 네 부품이 한 줄씩 떨어집니다.

오늘 답안의 매핑 표를 옆에 두고 코드를 만나면 "아, 이 부분이 그거구나" 의 감각으로 자연스럽게 흐를 거예요.

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.