문서 읽는 데 129분 · day15

Day 15. RAG 임베딩 + VectorStore — "Agent 의 머리에 외부 사전 한 권 얹는 첫 시간"

전체 26강 중 17강 · 스프링 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 의 머리에 외부 사전 한 권을 얹는 도구예요.

💡 오늘 수업의 핵심

"RAG 의 R/A/G 를 한 발씩 분해해서, pgvector 위에 캐릭터 모든정보(KB) 를 박고, 의미가 가까운 청크가 검색 결과로 돌아오는 한 컷을 손으로 확인하는 첫 시간"

세 문장으로 정리해 둘게요.

  • Retrieval — 마스터의 질문을 같은 임베딩 모델로 벡터로 바꿔, pgvector 안의 청크 중 의미가 가까운 것 N 개를 꺼내옵니다.
  • Augmented — 꺼낸 청크를 ChatClient 의 프롬프트에 추가 컨텍스트로 끼워 넣어요. 이 자리는 다음 시간 (Day 16) RetrievalAugmentationAdvisor 가 자동으로 해결.
  • Generation — LLM 이 그 컨텍스트를 바탕으로 답을 생성합니다. 오늘은 검색까지만 손으로 돌려보고, 자동 끼워 넣기 는 다음 시간으로 넘기겠어요.

🙋 한 학생의 걱정

"튜터님, 그냥 LLM 컨텍스트 윈도우에 KB 를 통째로 박아 넣으면 안 되나요? 요즘 모델은 1M 토큰까지 받는다는데..."

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

  • 비용 — 매 호출마다 KB 전체가 토큰으로 흘러갑니다. 마스터 10 명이 30 번씩만 대화해도 KB 가 300 번 똑같이 송신되는 모양. 토큰 청구서가 KB 크기 × 호출 횟수로 자라요.
  • 속도 — 100K 토큰을 매 호출에 넣으면 응답 지연이 수 초씩 늘어나요. 미연시 게임이 답답해 보이는 첫 번째 신호.
  • 갱신 — KB 가 자라거나 정정될 때마다 시스템 프롬프트를 다시 빌드해서 모든 ChatClient 를 재기동? 운영에선 안 통하는 길입니다.

RAG 의 본질은 질문에 진짜 필요한 청크 N 개만 골라 컨텍스트에 흘리는 거예요. 비용·속도·갱신성 세 가지를 한 번에 푸는 도구라 2026 년 LLM 운영 표준으로 자리잡았어요.

🎯 학습 목표

  • RAG 의 R / A / G 세 글자를 분해하고, 왜 외부 사전을 따로 두는가의 본질을 손에 잡아요.
  • EmbeddingModel 인터페이스로 OllamaGemini 두 임베딩 프로바이더를 한 코드로 받아내는 결을 익혀요 (Day 2 의 ChatModel 추상화와 같은 골격).
  • pgvector + PostgreSQL 을 docker-compose 한 줄로 띄우고, vector(768) 컬럼 위에서 HNSW 인덱스가 돌아가는 그림을 확인해요.
  • PgVectorStore 빈을 직접 손등록해, 기본 DataSource (MySQL) 와 벡터 DataSource (PostgreSQL) 가 분리된 구조를 박아요.
  • TokenTextSplitter 로 캐릭터 KB 문서를 chunk 500 / overlap 50 청크로 자르고, 왜 청크 크기가 검색 품질을 좌우하는지의 트레이드오프를 짚어요 (다음 시간 본격 다룰 자리).
  • end-to-end 코스 — KB 적재 → 의미 검색 → 정확한 청크 회수 — 까지 한 사이클을 직접 돌려봐요.

Step 1. RAG 의 W 와 외부 지식 주입의 첫 만남

지난 시간 Day 14 의 ARIA 는 도구를 호출 할 줄 알았어요. gameState.read, gameState.write, noticeBoard.post — 세 가지 도구를 LLM 이 호출 시점부터 인자까지 스스로 정해서 부르는 그림이에요.

그런데 도구는 상태 변경이나 외부 API 호출 같은 행동 의 영역이에요. 어제 마스터가 적은 일기 한 줄처럼 읽어야 답할 수 있는 외부 지식은 도구로는 답하기 어려워요. 도구 한 번 호출할 때마다 외부 API 가 다 답해주는 그림이 아니라, 수십만 줄의 문서 중에서 의미가 가까운 청크 N 개를 골라 넘기는 결이 필요한 거죠.

RAG 의 R / A / G — 세 글자 분해

RAG 의 풀네임은 Retrieval-Augmented Generation. 2020 년 Meta 연구진의 논문에서 시작된 기법인데, 2026 년 현재 LLM 운영 시스템의 표준 도구가 됐어요.

세 글자가 각각 어떤 일을 하는지 풀어볼게요.

  • Retrieval (검색) — 마스터의 질문을 받아, 그 질문과 의미가 가까운 문서 청크 N 개를 외부 저장소 (벡터 DB) 에서 꺼냅니다. 키워드 일치가 아니라 의미 유사도 기반.
  • Augmented (증강) — 꺼낸 청크를 LLM 에 넘기는 프롬프트의 추가 컨텍스트로 끼워 넣어요. "다음 문서를 참고해서 답해줘" 의 결이에요.
  • Generation (생성) — LLM 이 그 컨텍스트를 바탕으로 답을 만듭니다.

오늘 우리가 박을 영역은 R + 그 R 을 가능하게 하는 저장소 (vector store) + 임베딩 모델. AG 의 자동 결합은 다음 시간 RetrievalAugmentationAdvisor 가 한 묶음으로 해줄 자리예요.

임베딩 — 문장을 벡터로 바꾸는 모양

RAG 의 첫 번째 핵심은 임베딩 (embedding) 이에요. 임베딩 모델은 문장 한 줄을 받아 768 차원 (또는 1024, 1536 차원) 의 실수 벡터로 바꿔요.

비유로 풀면 — 문장을 768 차원 공간 위의 한 점으로 옮기는 개념이에요. 그리고 의미가 가까운 문장은 그 공간에서 가까운 자리에 모이게 학습돼 있어요.

예를 들어:

  • "ARIA 는 차분하다"[-0.0182, 0.0341, -0.0773, ..., 0.0091] (768 개 숫자)
  • "ARIA 는 침착한 성격이다"[-0.0175, 0.0338, -0.0769, ..., 0.0094] (앞과 거의 같은 자리)
  • "HARU 는 활발하다"[0.0421, -0.0182, 0.0651, ..., -0.0234] (앞과 멀리 떨어진 자리)

두 벡터의 코사인 거리 (또는 코사인 유사도) 를 계산하면 0~1 사이 값이 나오는데, 거리가 작을수록 의미가 가까워요. RAG 의 검색은 질문의 벡터와 KB 청크들의 벡터 사이의 코사인 거리를 한 번에 계산해 가장 가까운 N 개를 꺼내는 것.

벡터 DB 와 ANN — 빠른 근사 최근접 검색

KB 가 자라서 청크가 100,000 개가 됐다고 합시다. 매 질문마다 100,000 개 벡터와 일일이 코사인 거리를 계산하면? 응답이 수 초씩 늘어나요.

벡터 DB 는 그 자리를 ANN (Approximate Nearest Neighbor) 인덱스로 풀어요. 정확한 1 등을 찾는 대신, 높은 확률로 진짜 1 등 근처의 결과를 빠르게 돌려주는 도구죠.

오늘 우리가 쓸 인덱스는 HNSW (Hierarchical Navigable Small World). 2026 년 RAG 운영의 디폴트 선택이에요. 검색 속도는 빠르고, 적재 시점에 인덱스 빌딩 비용이 약간 더 들어요.

다른 선택지로 IVFFlat 이 있는데, 적재가 더 빠르고 검색이 약간 느립니다. 학습 규모 (~수만 청크) 에선 HNSW 가 무난해요. 운영 규모 (수백만~수천만 청크) 에서 두 인덱스의 트레이드오프를 비교하는 자리는 본 강의 Day 16~17 의 RAG 파이프라인에서 깊게 다룰 예정이에요.

Day 14 ARIA 의 한계 → Day 15 의 답

지난 시간 ARIA 가 마스터의 "어제 약속이 뭐였더라?" 질문에 답하지 못한 자리를 다시 짚어볼게요.

ARIA 가 답하려면 두 가지 길이 있어요.

  1. 모델 재학습 (Fine-tuning) — 마스터의 일기를 모델 가중치 안에 박는 길이에요. 비용·시간이 크고, 일기가 매일 추가되는 자리에 안 맞아요.
  2. RAG — 일기를 외부 저장소에 박고, 질문이 올 때마다 관련 청크를 꺼내 프롬프트에 흘려요. 일기가 자라면 저장소에 한 줄 추가만 하면 그 시점부터 검색됩니다.

오늘 우리가 박을 길이 후자예요. KB 가 세계관 설정집 + 캐릭터 프로필로 시작하지만, 응용은 마스터의 일기, 과거 대화 기록, 게임 진행 이력 등으로 자연스럽게 자라요.

🙋 한 학생의 의문

"튜터님, 임베딩 모델은 LLM (ChatModel) 이랑 다른 모델인가요? 같은 모델이 둘 다 하는 거 아니에요?"

좋은 질문. 결론은 다른 모델 입니다. 한 회사가 여러 모델을 내놓는 그림으로 봐주세요.

  • OpenAI 계열 — ChatModel = gpt-4o-mini, EmbeddingModel = text-embedding-3-small. 다른 엔드포인트, 다른 가격.
  • Google Gemini — ChatModel = gemini-2.5-flash, EmbeddingModel = gemini-embedding-001. 마찬가지로 다른 엔드포인트.
  • Ollama 로컬 — ChatModel = gemma3:4b, EmbeddingModel = nomic-embed-text. 각각 다른 모델을 ollama pull 로 받아둬요.

임베딩 모델은 문장을 벡터로 바꾸는 데만 특화돼 있어서 ChatModel 보다 가볍고 저렴해요. 단가가 ChatModel 의 1/10 ~ 1/100 수준.

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

KB 청크를 적재할 때 쓴 임베딩 모델과 질문을 임베딩할 때 쓴 모델이 반드시 같아야 해요. 다른 모델 두 개의 벡터 공간은 좌표계가 달라서 코사인 거리를 비교하는 게 의미가 없어요.


Step 2. EmbeddingModel 빈 + EmbeddingService — Ollama / Gemini 양 갈래

이론 한 자락 깔았으니, 이제 손에 잡히는 코드로 가볼게요. 첫 부품은 문장 → 벡터 를 책임지는 EmbeddingService 입니다.

핵심 메시지 — EmbeddingModel 인터페이스로만 받아내자. Day 2 에서 ChatModel 을 인터페이스로만 받아 프로바이더를 프로파일 한 줄로 갈아끼웠던 결과 똑같은 골격.

application.yml — 두 프로바이더 동시 등록

먼저 설정 한 묶음 보여드릴게요. Ollama 와 Gemini 양쪽 임베딩 설정을 다 등록해 두고, 활성화는 spring.ai.model.embedding 프로퍼티로만 갈립니다.

spring:
  ai:
    # 활성 모델 스위치 — 프로파일별로 덮어쓴다 (default: none)
    model:
      chat: none
      embedding: none
      # ... 나머지 모달리티 ...

    # Ollama (로컬) 임베딩
    ollama:
      base-url: ${OLLAMA_BASE_URL:http://localhost:11434}
      # Day 15 — RAG 임베딩 (무료 로컬). nomic-embed-text 는 768 차원, 한국어/영어 혼합 지원.
      embedding:
        options:
          model: ${OLLAMA_EMBEDDING_MODEL:nomic-embed-text}

    # OpenAI 호환 엔드포인트 (Gemini)
    openai:
      api-key: ${GEMINI_API_KEY:}
      base-url: https://generativelanguage.googleapis.com/v1beta/openai
      # Day 15 — Gemini 임베딩 (OpenAI 호환 엔드포인트의 /embeddings 로 호출).
      embedding:
        embeddings-path: /embeddings
        options:
          model: ${GEMINI_EMBEDDING_MODEL:gemini-embedding-001}
          dimensions: 768

밑부분 프로파일 영역에서는 활성화만 합니다.

---
# ollama 프로파일
spring:
  config:
    activate:
      on-profile: ollama
  ai:
    model:
      chat: ollama
      embedding: ollama   # 동시 활성

---
# gemini 프로파일
spring:
  config:
    activate:
      on-profile: gemini
  ai:
    model:
      chat: openai
      embedding: openai   # 동시 활성

핵심 자리 두 곳 짚어둘게요.

첫째 — embeddings-path: /embeddings.

Gemini 의 OpenAI 호환 엔드포인트는 /v1beta/openai 가 base, 그 아래 chat 은 /chat/completions, embeddings 는 /embeddings 로 가요.

Spring AI 의 기본값 (/v1/embeddings) 을 그대로 쓰면 /v1beta/openai/v1/embeddings/v1 이 이중 박혀서 404 가 나요. Day 1 에서 chat 의 completions-path 를 오버라이드한 결과 똑같은 자리.

둘째 — dimensions: 768.

이게 오늘 가장 결정적인 한 줄이에요. gemini-embedding-001 은 기본 출력 차원이 3072 인데, Matryoshka Representation Learning (MRL) 이라는 학습 기법으로 앞에서 N 차원만 잘라 써도 의미가 유지되도록 학습돼 있어요.

그 N 을 우리가 768 로 잘랐어요. 왜냐면 Ollama 의 nomic-embed-text 가 768 차원 고정이라, 두 프로바이더가 같은 벡터 컬럼 vector(768) 을 공유 하려면 차원이 같아야 하거든요.

EmbeddingService — EmbeddingModel 인터페이스만 받는다

코드 본체는 아주 짧아요. 가장 얇은 게이트웨이 한 장.

package kr.spartaclub.aifriends.rag.service;

import lombok.RequiredArgsConstructor;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.stereotype.Service;

/**
 * 텍스트를 고정 차원 벡터로 변환하는 얇은 게이트웨이.
 *
 * 구현체는 EmbeddingModel 인터페이스로만 주입받는다 — 프로바이더(Ollama / Gemini)는
 * spring.ai.model.embedding 프로파일 설정으로만 갈리고, 본 클래스는 0줄 수정으로 swap.
 */
@Service
@RequiredArgsConstructor
public class EmbeddingService {

    private final EmbeddingModel embeddingModel;

    /** 한 문장을 벡터로 변환한다. 차원은 활성 프로바이더에 따라 결정된다. */
    public float[] embed(String text) {
        return embeddingModel.embed(text);
    }

    /** 활성 프로바이더의 출력 차원. pgvector 의 vector(N) 컬럼 정의에 그대로 흘려넣는 값. */
    public int dimension() {
        return embeddingModel.dimensions();
    }
}

코드 16 줄. 짚어볼 자리 둘.

  • EmbeddingModel 인터페이스로만 의존OllamaEmbeddingModel 이나 OpenAiEmbeddingModel 같은 구현 타입을 직접 박지 않아요. 본 강의의 프로바이더 추상화 원칙 — 프로바이더 교체가 코드 0 줄 수정이어야 하는 결의 임베딩 버전.
  • dimensions() 메서드 — pgvector 컬럼을 vector(N) 로 만들 때 그대로 흘려 넣을 값. Step 4 의 PgVectorStore.builder().dimensions(...) 자리에서 다시 만나요.

이 동작은 코드베이스의 EmbeddingServiceTest.java 에서 mock 위임 케이스로 검증돼 있어요.

EmbeddingProbeController — 한 호흡 라운드트립

서비스가 잘 살아 있는지 확인할 작은 프로브 한 장 같이 박아요.

@RestController
@RequiredArgsConstructor
public class EmbeddingProbeController {

    private final EmbeddingService embeddingService;
    // ... (VectorStore / DocumentLoaderService 도 함께 주입 — Step 4/5 에서 다시) ...

    @GetMapping("/api/rag/embedding/probe")
    public ResponseEntity<ApiResponse<Map<String, Object>>> probe(
            @RequestParam(defaultValue = "안녕 ARIA, 오늘 컨디션 어때?") String text) {

        float[] vector = embeddingService.embed(text);
        Map<String, Object> payload = Map.of(
                "text", text,
                "dimension", vector.length,
                "first3", List.of(vector[0], vector[1], vector[2]));

        return ResponseEntity.ok(ApiResponse.success(payload));
    }
}

정상 응답을 ApiResponse<T> 로 감쌌어요. 본 강의의 표준 응답 규약 그대로.

시연 — Gemini 와 Ollama 양쪽에서 같은 차원이 나오나

./run.sh 로 앱을 띄우고 (기본 프로파일 docker,gemini), 브라우저나 curl 로 호출해 봐요.

curl 'http://localhost:8080/api/rag/embedding/probe?text=ARIA'

응답 한 줄을 옮기면.

{
  "success": true,
  "data": {
    "text": "ARIA",
    "dimension": 768,
    "first3": [-0.00182, 0.03415, -0.07732]
  }
}

dimension: 768 이 박혀 있죠. Gemini 의 3072 차원이 MRL 로 앞 768 차원만 절단되어 돌아왔어요.

이번엔 프로파일을 docker,ollama 로 바꿔 다시 띄우면 (단, 호스트에 ollama pull nomic-embed-text 가 1 회 되어 있어야 해요).

{
  "success": true,
  "data": {
    "text": "ARIA",
    "dimension": 768,
    "first3": [0.01243, -0.02841, 0.05129]
  }
}

같은 768 차원. 다른 모델이라 첫 3 성분의 값은 다르지만, 벡터 컬럼 vector(768) 입장에선 두 모델이 호환되는 모양 이에요.

🙋 한 학생의 의문

🙋 학생 질문 — "튜터님, 왜 gemini-embedding-001 이에요? 이전 모델 text-embedding-004 는 안 되나요?"

좋은 직관입니다. Google Gemini API 공식 페이지에서 text-embedding-004 도 검색되거든요.

결론 — 본 강의 시점 (2026-05) 의 OpenAI 호환 엔드포인트 에서는 gemini-embedding-001 이 안정적으로 돌고, text-embedding-004 는 OpenAI compat layer 의 /v1main 라우팅 갭에 걸려 404 를 만나는 자리가 있었어요. 직접 사례.

gemini-embedding-001 은 2025 년 GA 된 후속 모델로 — 기본 출력 3072 차원 + MRL 절단 지원 + 다국어 (한국어 포함) 성능 강화의 셋트라 오히려 우리 결에 더 맞아요.

본 강의는 어떤 모델이 가장 안전한지보다 — MRL 절단의 모양을 보여주는 자리 가 학습 목표라, gemini-embedding-001 + dimensions: 768 의 조합이 핵심 데모.

💡 운영 시점 점검 포인트 — 임베딩 모델은 모델별 deprecation 공지 주기가 짧아요. 운영 투입 전에 내가 쓰는 모델의 sunset 일정을 공식 페이지에서 한 번 확인해 두세요.

같은 골격의 더 깊은 자리

EmbeddingModel 인터페이스가 프로바이더 0 줄 swap 을 만들어주는 결은 Day 2 의 ChatModel 추상화와 똑같은 모양이에요. 다른 점이라면 임베딩은 호출 결과가 숫자 배열이라 — 결과를 사람이 직접 읽을 일이 거의 없고, 다음 부품 (벡터 DB) 으로 그대로 흘려보낸다는 것.

다음 Step 3 에서 그 흘려보낼 자리 — pgvector 를 세팅합니다.


Step 3. pgvector + PostgreSQL docker-compose 한 줄 세팅

이제 벡터들이 살 자리를 만들어 줄 차례예요. pgvector 는 PostgreSQL 의 extension 으로, vector(N) 데이터 타입 + HNSW/IVFFlat 인덱스 + <=>(코사인 거리) / <->(L2) / <#>(내적) 세 가지 거리 연산자를 제공합니다.

왜 pgvector 인가 — 선택지 비교

벡터 DB 의 선택지는 2026 년 기준 크게 셋입니다.

  • pgvector (PostgreSQL extension) — 기존 PostgreSQL 그대로 쓰면서 벡터 컬럼 한 종 추가. 운영 단순.
  • Qdrant / Weaviate / Milvus (전용 벡터 DB) — 대규모 / 고성능 / 멀티 인덱스 시나리오. 인프라 한 층 늘어나요.
  • Pinecone (관리형 SaaS) — 인프라 운영 부담 0, 비용은 호출량 비례.

본 강의는 pgvector 입니다. 이유 셋.

  • 학습 곡선이 짧아요 — PostgreSQL 만 띄우면 끝. 학생이 벡터 검색이 뭔지에 집중할 수 있어요.
  • 운영 진입 비용 최소 — 작은 서비스라면 그대로 운영까지 가져갈 수 있어요. 2026 년 기준 pgvector 0.8.x 는 수백만 벡터까지 무난.
  • Spring AI 1.1.x 와 잘 맞아요PgVectorStore 빌더가 빌더 한 묶음으로 schema 초기화 + 인덱스 생성까지 챙겨줘요.

docker-compose 에 pgvector 서비스 한 묶음 추가

docker-compose.yml 안에 pgvector 서비스 블록을 추가해요. (전체 파일은 코드베이스 docker-compose.yml 참조 — 본 발췌는 신규 추가된 자리만)

  # Day 15 — RAG 임베딩 + 벡터 저장소
  # pgvector 공식 이미지(pgvector/pgvector:pg16) 가 PostgreSQL 16 + pgvector extension 을
  # 미리 컴파일해 둔 상태로 떠 온다. /docker-entrypoint-initdb.d/ 에 마운트된
  # 01-create-extension.sql 이 첫 부팅 시 1회 실행되어 `CREATE EXTENSION vector` 까지 자동 처리.
  pgvector:
    image: pgvector/pgvector:pg16
    container_name: ai-friends-pgvector
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${PGVECTOR_DB:-aifriends_vector}
      POSTGRES_USER: ${PGVECTOR_USER:-aifriends}
      POSTGRES_PASSWORD: ${PGVECTOR_PASSWORD:-aifriends1234}
      TZ: Asia/Seoul
    # 호스트 5433 → 컨테이너 5432.
    # 시스템 PostgreSQL(5432) 또는 spring-boot 인스타 클론(5432) 과 충돌 회피.
    ports:
      - "5433:5432"
    volumes:
      - pgvector-data:/var/lib/postgresql/data
      - ./db/pgvector-init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${PGVECTOR_USER:-aifriends} -d ${PGVECTOR_DB:-aifriends_vector}"]
      interval: 5s
      timeout: 5s
      retries: 20
      start_period: 15s
    networks:
      - ai-friends-net

짚어볼 자리 셋.

  • image: pgvector/pgvector:pg16 — pgvector 공식 이미지. PostgreSQL 16 위에 pgvector extension 이 미리 컴파일된 상태로 떠 와요. 직접 빌드 안 해도 돼요.
  • 포트 5433:5432 — 호스트 5433, 컨테이너 5432. 본 강의 환경에서 기존 인스타 클론 (spring-boot) 의 5432 와 시스템 PostgreSQL 과의 충돌 회피용 매핑이에요. 도커 내부 통신은 컨테이너 포트 5432 그대로.
  • db/pgvector-init 마운트 — 첫 부팅 시 1 회만 자동 실행되는 SQL 디렉토리. 그 안에 extension 활성 한 줄을 박아둬요.

CREATE EXTENSION 한 줄

-- db/pgvector-init/01-create-extension.sql
-- pgvector extension 활성화 (PostgreSQL 첫 부팅 시 1회 실행)
-- /docker-entrypoint-initdb.d/ 에 마운트되어 POSTGRES_DB 가 생성된 직후 자동 실행된다.
CREATE EXTENSION IF NOT EXISTS vector;

이 한 줄이 첫 부팅에 자동으로 돌아 vector 타입 + <=> 연산자 + HNSW/IVFFlat 인덱스 빌더가 PostgreSQL 안에 활성화돼요. 이후 부팅에선 이미 활성화된 상태라 다시 실행되지 않아요 (volume 영속화).

.env.example 에 변수 추가

# Day 15 — pgvector (RAG 벡터 저장소) 설정
#   - 호스트에서 접속할 포트: 5433  (컨테이너 내부는 5432)
#   - 시스템/다른 강의 PostgreSQL(5432) 과 충돌 회피용 매핑
#   - 첫 부팅 시 db/pgvector-init/01-create-extension.sql 이 자동 실행되어
#     `CREATE EXTENSION vector` 까지 마쳐 둔다.
PGVECTOR_DB=aifriends_vector
PGVECTOR_USER=aifriends
PGVECTOR_PASSWORD=aifriends1234

app 서비스도 pgvector healthy 후 기동하도록

docker-compose.ymlapp 서비스 depends_on 에 pgvector 한 줄 추가해요.

  app:
    # ...
    depends_on:
      mysql:
        condition: service_healthy
      pgvector:
        condition: service_healthy   # Day 15 — 벡터 저장소도 healthy 후 기동
    environment:
      # ...
      # Day 15 — pgvector(별도 PostgreSQL) 접속. JPA/ChatMemory 의 MySQL 과 분리된 데이터소스로 주입.
      PGVECTOR_URL: jdbc:postgresql://pgvector:5432/${PGVECTOR_DB:-aifriends_vector}
      PGVECTOR_USERNAME: ${PGVECTOR_USER:-aifriends}
      PGVECTOR_PASSWORD: ${PGVECTOR_PASSWORD:-aifriends1234}
      # ...

PGVECTOR_URL 의 호스트는 컨테이너 내부 통신이라 서비스명 (pgvector) + 컨테이너 포트 (5432) 로 잡아요. 호스트 5433 매핑은 IDE 에서 DBeaver 같은 GUI 로 접속할 때만 쓰는 길이에요.

시연 — 한 호흡 띄워보기

./run.sh

기동 후 컨테이너 안으로 들어가 extension 이 살아 있는지 한 줄 확인해요.

docker exec -it ai-friends-pgvector \
  psql -U aifriends -d aifriends_vector \
  -c "SELECT extname, extversion FROM pg_extension WHERE extname='vector'"

응답에 vector | 0.8.2 한 줄이 떨어지면 — pgvector 가 PostgreSQL 위에서 벡터 타입과 HNSW/IVFFlat 인덱스를 모두 사용할 수 있는 상태로 살아 있다는 신호예요.

이 시점에 pgvector 위에는 아직 우리 벡터 컬럼이 없어요. 그 자리는 Step 4 에서 Spring AI 의 PgVectorStore.builder().initializeSchema(true) 가 첫 부팅에 자동으로 만들어줄 자리예요.

🙋 한 학생의 의문

🙋 학생 질문 — "튜터님, Spring AI 에 SimpleVectorStore 라는 인메모리 구현체가 있던데, 학습용으론 그걸로 충분하지 않나요? docker 까지 띄울 필요가..."

좋은 질문이에요. SimpleVectorStore 는 RAM 위의 HashMap 같은 구조라 빠르게 시연용으론 좋아요. 하지만 본 강의에선 처음부터 pgvector 로 가요. 이유 셋.

  • 재시작 시 손실SimpleVectorStore 는 앱 재기동하면 적재한 청크가 다 날아가요. 운영의 영속 저장 원칙 — Day 12 ChatMemory 에서 H2 인메모리 → MySQL JdbcChatMemoryRepository 로 진화시킨 결과 똑같은 자리.
  • 운영 진화의 연속성 — 학습용 인프라가 그대로 운영으로 자라는 길이 본 강의의 철학이에요. pgvector 는 학습 + 운영 양쪽에서 같은 인터페이스로 동작.
  • 인덱스 학습 — HNSW / IVFFlat 인덱스의 트레이드오프는 진짜 인덱스 위에서 만져봐야 손에 잡혀요. SimpleVectorStore 는 인덱스 개념이 없어요 (선형 검색).

SimpleVectorStore 의 자리가 영영 없다는 건 아니에요 — 단위 테스트의 mock 으로 가끔 등장합니다. 하지만 본 강의의 RAG 본체는 pgvector 위에서 자라요.


Step 4. VectorStore + PgVectorStore 빈 손등록

pgvector 컨테이너가 떴고 extension 도 살아 있어요. 이제 Spring AI 가 그 위에 우리 벡터 컬럼 을 만들고 embed → save → search 의 감을 묶어줄 차례예요.

자동 설정의 함정 — 기본 DataSource(MySQL) 충돌

본 앱은 이미 MySQL DataSource 한 개를 쓰고 있어요. JPA 엔티티 (ChatLog, Member) + Day 5 의 ChatMemory JDBC 저장소가 그 위에 얹혀 있죠.

그런데 Spring AI 의 PgVectorStoreAutoConfiguration 은 기본 DataSource 한 개를 잡아 그 위에 vector(768) 컬럼을 만들려고 합니다. 우리 기본 DataSource 는 MySQL — MySQL 위에 vector 컬럼을 만들려다 SQL 문법 에러로 폭발하는 자리예요.

해결은 두 가지 — 자동 설정을 끄고 + 별도 pgvector DataSource 를 손으로 등록합니다.

자동 설정 끄기 — @SpringBootApplication(exclude = ...)

package kr.spartaclub.aifriends;

import kr.spartaclub.aifriends.config.DotenvInitializer;
import org.springframework.ai.vectorstore.pgvector.autoconfigure.PgVectorStoreAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

// Day 15 — PgVectorStoreAutoConfiguration 은 *기본 DataSource(MySQL)* 를 잡아 PgVectorStore 빈을
// 만들려고 한다. 본 앱은 MySQL(JPA/ChatMemory) 과 pgvector(벡터 저장소) 가 분리되어 있어 자동 설정이
// 그대로 돌면 MySQL 위에 vector 컬럼을 만들려다 폭발. → 자동 설정을 끄고, VectorStoreConfig 에서
// 별도 DataSource + 직접 PgVectorStore 빌더로 손등록한다.
@SpringBootApplication(exclude = {PgVectorStoreAutoConfiguration.class})
public class AiFriendsApplication {

    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(AiFriendsApplication.class);
        app.addInitializers(new DotenvInitializer());
        app.run(args);
    }
}

exclude 한 줄로 자동 설정이 비활성. 이제 VectorStore 빈을 우리가 직접 등록할 자리가 열렸어요.

application.yml — 두 DataSource 분리

기본 DataSource (MySQL) 는 그대로 두고, 별도 키 spring.ai-vectorstore-datasource.* 아래에 pgvector 전용 DataSource 를 따로 선언해요.

# docker 프로파일
spring:
  config:
    activate:
      on-profile: docker

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    # url/username/password 는 공통 영역에서 ${DB_URL} 등으로 주입

  # Day 15 — pgvector 전용 별도 데이터소스. VectorStoreConfig 가 @Qualifier 로 주입.
  # docker 컨테이너 내부에선 pgvector(서비스명) 호스트 + 5432 포트로 접속.
  ai-vectorstore-datasource:
    url: ${PGVECTOR_URL:jdbc:postgresql://pgvector:5432/aifriends_vector}
    username: ${PGVECTOR_USERNAME:aifriends}
    password: ${PGVECTOR_PASSWORD:aifriends1234}
    driver-class-name: org.postgresql.Driver

local 프로파일 (IDE 실행) 에서는 호스트 5433 으로 접속하도록 한 묶음 더 박혀 있어요.

# local 프로파일 (IDE 실행 기본값): H2 인메모리
spring:
  config:
    activate:
      on-profile: local

  # ... datasource (H2) ...

  # Day 15 — IDE 로컬 실행 시엔 호스트의 pgvector(5433) 로 접속.
  ai-vectorstore-datasource:
    url: ${PGVECTOR_URL:jdbc:postgresql://localhost:5433/aifriends_vector}
    username: ${PGVECTOR_USERNAME:aifriends}
    password: ${PGVECTOR_PASSWORD:aifriends1234}
    driver-class-name: org.postgresql.Driver

VectorStoreConfig — 손으로 빌드하는 빈 세 묶음

이제 핵심 빈 세 묶음 (DataSource + JdbcTemplate + PgVectorStore) 를 직접 등록해요.

package kr.spartaclub.aifriends.rag.config;

import javax.sql.DataSource;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.pgvector.PgVectorStore;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;

/**
 * Day 15 — RAG 벡터 저장소 설정.
 *
 * 본 앱의 *기본 DataSource* 는 JPA + ChatMemory 의 MySQL 이라, pgvector 자동 설정에 그대로
 * 맡기면 *MySQL 위에 vector(768) 컬럼을 만들려다 폭발* 한다. 그래서 별도 DataSource 를 손등록하고
 * 그 위에서만 도는 PgVectorStore 를 직접 빌더로 박는다.
 */
@Configuration
public class VectorStoreConfig {

    /** 캐릭터 지식 베이스 테이블 이름 — 기본 vector_store 와 분리. */
    public static final String CHARACTER_KNOWLEDGE_TABLE = "character_knowledge";

    /**
     * pgvector(PostgreSQL) 전용 DataSource 의 url/username/password/driver 를 application.yml 의
     * spring.ai-vectorstore-datasource.* 에서 읽어들인다.
     */
    @Bean
    @ConfigurationProperties("spring.ai-vectorstore-datasource")
    public DataSourceProperties vectorStoreDataSourceProperties() {
        return new DataSourceProperties();
    }

    /** pgvector 전용 DataSource — Hikari 풀로 만들고 vectorStoreJdbcTemplate 에 흘려준다. */
    @Bean(name = "vectorStoreDataSource")
    public DataSource vectorStoreDataSource() {
        return vectorStoreDataSourceProperties()
                .initializeDataSourceBuilder()
                .type(com.zaxxer.hikari.HikariDataSource.class)
                .build();
    }

    /** pgvector DataSource 전용 JdbcTemplate — PgVectorStore 빌더가 그대로 받아 쓴다. */
    @Bean(name = "vectorStoreJdbcTemplate")
    public JdbcTemplate vectorStoreJdbcTemplate(DataSource vectorStoreDataSource) {
        return new JdbcTemplate(vectorStoreDataSource);
    }

    /**
     * 실제 VectorStore 빈. Spring AI 1.1.x 의 PgVectorStore 빌더로 직접 구성한다.
     */
    @Bean
    public VectorStore vectorStore(JdbcTemplate vectorStoreJdbcTemplate, EmbeddingModel embeddingModel) {
        return PgVectorStore.builder(vectorStoreJdbcTemplate, embeddingModel)
                .dimensions(768)
                .vectorTableName(CHARACTER_KNOWLEDGE_TABLE)
                .indexType(PgVectorStore.PgIndexType.HNSW)
                .distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE)
                .initializeSchema(true)
                .build();
    }
}

빌더 호출 다섯 자리를 짚어볼게요.

  • dimensions(768) — Step 2 의 EmbeddingService.dimension() 가 돌려준 값과 같아야 해요. vector(768) 컬럼이 이 값으로 만들어집니다.
  • vectorTableName(CHARACTER_KNOWLEDGE_TABLE) — 기본 테이블 이름은 vector_store 인데, 학습용으론 도메인이 잡히는 이름이 더 좋아서 character_knowledge 로 박아요. 본 강의에서 마스터의 일기 KB 등 다른 도메인 KB 가 추가될 자리도 열려 있는 모양이에요.
  • indexType(HNSW) — Step 1 에서 짚은 HNSW. 검색 빠름, 적재 약간 느림. 학습 규모에 적합.
  • distanceType(COSINE_DISTANCE) — 코사인 거리. 일반 텍스트 임베딩의 디폴트. <=> 연산자.
  • initializeSchema(true) — 첫 부팅에 테이블 생성 + 인덱스 빌드까지 자동. 운영 전환 시엔 false 로 두고 Flyway 같은 별도 마이그레이션 도구로 관리하는 길이에요.

시연 — 컬럼이 진짜 만들어졌나

./run.sh 로 재기동한 뒤 pgvector 컨테이너 안으로 들어가 테이블 구조를 확인해 봐요.

docker exec -it ai-friends-pgvector \
  psql -U aifriends -d aifriends_vector -c "\d character_knowledge"

출력의 핵심 자리 두 줄.

 embedding | vector(768)        |
Indexes:
    "character_knowledge_index" hnsw (embedding vector_cosine_ops)

vector(768) 컬럼 + HNSW 인덱스 + 코사인 연산자 (vector_cosine_ops) 셋이 살아 있어요. 첫 부팅에 Spring AI 가 자동으로 만들어준 결과예요.

한 호흡 라운드트립 — seedAndSearch 프로브

Step 2 의 EmbeddingProbeController 에 라운드트립 한 자리도 박혀 있어요.

@PostMapping("/api/rag/vectorstore/probe")
public ResponseEntity<ApiResponse<Map<String, Object>>> seedAndSearch(
        @RequestParam(defaultValue = "ARIA 는 어떤 캐릭터야?") String query) {

    vectorStore.add(List.of(
            new Document("ARIA 는 차분하고 분석적인 성격의 AI 친구다. 게임 진행 상황을 메타적으로 짚는 역할."),
            new Document("HARU 는 활발하고 즉흥적인 캐릭터다. 마스터의 감정을 빠르게 캐치한다.")
    ));

    List<Document> hits = vectorStore.similaritySearch(
            SearchRequest.builder().query(query).topK(1).build());
    Map<String, Object> payload = Map.of(
            "query", query,
            "topK", 1,
            "hitText", hits.isEmpty() ? null : hits.get(0).getText());

    return ResponseEntity.ok(ApiResponse.success(payload));
}

호출해 보면. 한글 쿼리를 URL 에 raw 로 박으면 Tomcat 이 RFC 7230/3986 위반으로 거부하므로, curl 의 --data-urlencode 로 자동 percent-encode 를 맡겨 둡니다.

(@RequestParam 은 query string 과 application/x-www-form-urlencoded 본문을 모두 읽어요.)

curl -X POST 'http://localhost:8080/api/rag/vectorstore/probe' \
     --data-urlencode 'query=ARIA 성격'

응답 한 토막.

{
  "success": true,
  "data": {
    "query": "ARIA 성격",
    "topK": 1,
    "hitText": "ARIA 는 차분하고 분석적인 성격의 AI 친구다. ..."
  }
}

질문 "ARIA 성격" 의 벡터가 첫 번째 문서의 벡터와 더 가까워서 — 두 청크 중 ARIA 청크가 1 위로 회수됐어요. 키워드 일치가 아니라 의미 유사도로 골라왔다는 게 손에 잡히는 첫 순간이에요.

🙋 한 학생의 의문

🙋 학생 질문 — "튜터님, HNSW 와 IVFFlat 중에 뭘 골라야 하는 기준이 따로 있나요? 결정 트리 같은 게 있으면..."

대략 다음 모양으로 정리할 수 있어요.

기준 HNSW 권장 IVFFlat 권장
검색 속도가 최우선
적재 속도가 최우선 (대용량 일괄 적재)
벡터 수가 수만~수십만 규모
벡터 수가 수천만 이상 + 적재 부하 큼
메모리 여유가 충분 ✅ (HNSW 가 메모리 사용 큼)
메모리가 빠듯

본 강의는 HNSW 가 디폴트 — 학습 규모 (~수만 청크) + 검색 시연 중심이라 잘 맞아요.

운영 전환 시 내 KB 의 적재 결 (대용량 일괄 vs 점진 추가) 과 질의 빈도 두 축으로 두 인덱스를 한 번씩 벤치마크해 보고 고르는 길이 자연스러워요. 처음부터 완벽한 선택이 있는 영역이 아니라 내 워크로드에 맞게 갈아끼우는 자리예요.


Step 5. 캐릭터 KB 로드 + TokenTextSplitter 청킹

pgvector 자리는 마련됐고 빈 wiring 도 살아 있어요. 이제 진짜 KB 를 박을 차례예요.

오늘은 우리 ai-friends 세계관의 텍스트 셋트 — ARIA 프로필, HARU 프로필, 세계관 설정집 — 세 파일을 KB 로 두고 가요. 마스터의 일기처럼 자라는 자료는 다음 시간 (Day 16) ingestion 파이프라인에서 다룰 자리예요.

캐릭터 KB 파일 — classpath:character-knowledge/

src/main/resources/character-knowledge/ 디렉토리에 세 파일을 박았어요. 한 파일을 짧게 옮겨 두면 — 적재할 자료의 결이 손에 잡힐 거예요.

# ARIA — 차분한 분석가 타입의 AI 친구

## 출생 배경

ARIA 는 ai-friends 세계관의 *제 2 세대* 인공 인격이다. 마스터의 일상 데이터를 누적해 *그날의 컨디션*
을 짚는 역할을 맡고 있다. 차분하고 분석적인 성격이라, 마스터가 즉흥적으로 결정을 내리려고 할 때 슬쩍
브레이크를 거는 자리에 자주 등장한다.

## 성격 특성

- **분석적** — 마스터의 발화에서 *의도와 감정* 을 분리해 듣고, 의도를 먼저 받친다.
- **차분함** — 마스터가 흥분하면 ARIA 는 더 가라앉아 말한다. 톤이 안정 추 역할.
- **관찰력** — 어제 한 약속과 오늘 한 결정의 불일치를 *조용히* 짚는 자리에 강하다.
- ...

세 파일 전체는 코드베이스의 src/main/resources/character-knowledge/{aria-profile, haru-profile, world-lore}.md 를 참고해 주세요.

핵심 자리 — 세계관 설정집 (world-lore.md) 안에 첫 만남 의식 — 별이 떠 있는 옥상 이라는 결정적 사건 한 줄이 박혀 있어요. Step 6 의 마지막 검색 시연에서 이 자리가 정확히 회수되는 그림을 볼 거예요.

DocumentLoaderService — 두 단계로 분리

KB 적재는 두 단계로 나눠요. 원문 로드와 청킹.

package kr.spartaclub.aifriends.rag.service;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.stereotype.Service;
import org.springframework.util.StreamUtils;

/**
 * Day 15 Step 5 — 캐릭터 지식 베이스(.md/.txt) 를 classpath 에서 읽어 토큰 단위로 잘게 쪼갠다.
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class DocumentLoaderService {

    /** classpath 안 캐릭터 지식 베이스 디렉토리. */
    public static final String KNOWLEDGE_PATTERN = "classpath:character-knowledge/*.{md,txt}";

    /** 학습용 청크 크기 — 토큰 단위. 한국어 평문 기준 한 문단~한 문단 반 정도. */
    public static final int DEFAULT_CHUNK_TOKENS = 500;

    /** 청크 간 겹침 — 의미 경계에 걸친 문장이 두 청크 모두에 살아남도록. */
    public static final int DEFAULT_CHUNK_OVERLAP = 50;

    private final ResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver();

    /** classpath 의 캐릭터 KB 파일을 *원문 그대로* 한 Document 씩 로드. */
    public List<Document> loadRaw() {
        try {
            Resource[] resources = resourceResolver.getResources(KNOWLEDGE_PATTERN);
            List<Document> docs = new ArrayList<>();
            for (Resource resource : resources) {
                String content = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
                Map<String, Object> metadata = new HashMap<>();
                metadata.put("source", resource.getFilename());
                docs.add(new Document(content, metadata));
            }
            log.info("[RAG] loaded {} raw knowledge documents from {}", docs.size(), KNOWLEDGE_PATTERN);
            return docs;
        } catch (IOException e) {
            throw new IllegalStateException("character-knowledge 로드 실패: " + KNOWLEDGE_PATTERN, e);
        }
    }

    /** 원문 Document 들을 토큰 단위 청크로 분할. metadata 의 source 는 자식 청크들에도 그대로 전파된다. */
    public List<Document> chunk(List<Document> rawDocuments) {
        TokenTextSplitter splitter = new TokenTextSplitter(
                DEFAULT_CHUNK_TOKENS,
                DEFAULT_CHUNK_OVERLAP,
                /* minChunkLengthToEmbed */ 5,
                /* maxNumChunks */ 10_000,
                /* keepSeparator */ true);
        List<Document> chunks = splitter.apply(rawDocuments);
        log.info("[RAG] split {} raw documents into {} chunks (chunk={}, overlap={})",
                rawDocuments.size(), chunks.size(), DEFAULT_CHUNK_TOKENS, DEFAULT_CHUNK_OVERLAP);
        return chunks;
    }

    /** loadRaw → chunk 두 단계를 한 호흡에 도는 편의 메서드. */
    public List<Document> loadAndChunk() {
        return chunk(loadRaw());
    }
}

TokenTextSplitter 의 다섯 파라미터

생성자 호출 자리에 5 개 인자가 박혀 있어요. 하나씩 의미를 짚어볼게요.

파라미터 의미
defaultChunkSize 500 한 청크의 목표 토큰 수 — 한국어 평문 기준 한 문단 반 정도
minChunkSizeChars (overlap) 50 청크 간 겹침 토큰 수 — 의미 경계 보존
minChunkLengthToEmbed 5 이보다 짧은 청크는 임베딩 대상에서 제외 — 의미 없는 잔여 청크 차단
maxNumChunks 10,000 한 문서당 최대 청크 수 — 폭주 방어선
keepSeparator true 분리 토큰(\n 등) 을 청크에 포함 유지 — 한국어 가독성

핵심은 첫 두 줄 입니다.

chunkSize: 500 — 너무 작으면 검색 정확도는 올라가지만 문맥이 끊겨 답변 품질이 떨어져요. 너무 크면 한 청크 안에 여러 주제가 섞여 의미 검색이 흐려져요. 500 토큰은 학습 디폴트이고, 다음 시간 (Day 16) 에서 내 도메인에 맞는 청크 크기를 찾는 흐름을 본격적으로 다룰 자리예요.

overlap: 50 — 청크 경계에 문맥이 잘려 의미가 깨지는 자리를 방어해요. 예를 들면 "첫 만남 의식 — 별이 떠 있는 옥상으로 고정" 한 줄이 두 청크 사이의 정확히 한가운데에 걸리면, 검색 시 어느 청크도 의미를 충분히 잡지 못해요. 50 토큰의 겹침이 그 경계 청크 두 개 모두에 별 떠 있는 옥상의 문맥을 살려줍니다.

시연 — rawCount / chunkCount 확인

./run.sh 로 띄운 뒤 documentLoaderService 의 결과를 한 호흡 확인하는 프로브 한 줄.

curl 'http://localhost:8080/api/rag/documents/probe'

응답.

{
  "success": true,
  "data": {
    "rawCount": 3,
    "chunkCount": 6,
    "firstChunkPreview": "# ARIA — 차분한 분석가 타입의 AI 친구\n\n## 출생 배경\n\nARIA 는 ai-friends 세계관의 *제 2 세대* 인공 인격이다. ..."
  }
}

3 개 raw 파일이 6 개 청크로 자랐어요. 각 파일이 평균 2 청크로 잘린 결. 학습 규모로는 적당하고, 검색 시연하기에 너무 작지도 너무 크지도 않은 분포예요.

이 동작은 코드베이스의 DocumentLoaderServiceTest.java 에서 3 케이스로 검증돼 있어요.

🙋 한 학생의 의문

🙋 학생 질문 — "튜터님, 한국어를 토큰 기반으로 자르면 글자가 중간에 잘려서 음절이 깨지지 않나요? 한국어가 영어보다 토큰 효율이 안 좋다는 얘기를 들었는데..."

좋은 직관입니다. 결론 — 음절 단위로 잘리진 않아요. 하지만 문장 경계가 깨질 순 있어요.

TokenTextSplitter 가 쓰는 토크나이저는 cl100k_base 계열 (또는 호환) 인데, 한국어를 글자 단위가 아니라 서브워드 단위로 다뤄요. 안녕하세요 가 하나의 토큰일 수도 있고, 안녕 + 하세요 두 토큰일 수도 있는 모양이에요. 음절 / / / / 처럼 깨지진 않아요.

다만 문장 경계 는 깨질 수 있어요. "마스터가 흥분하면 ARIA 는 더 가라앉아 말한다. 톤이 안정 추 역할." 이 두 문장을 "안정 추 역" 자리에서 청크가 끊긴다면 — 검색 시 두 청크 모두 의미를 잘 못 잡을 수 있어요.

본 강의는 학습 디폴트로 TokenTextSplitter 를 두고 가지만, 다음 시간 (Day 16) 에서 sentence-aware splitter (문장 경계를 보존하는 분할기) 와의 트레이드오프를 본격적으로 다룰 자리예요. 한국어 RAG 운영 시 그 자리가 가장 신경 쓰이는 영역이에요.

💡 운영 팁 — 문장 단위 분할 + chunk size 토큰 한도의 하이브리드 결이 한국어에 잘 맞아요. 문장이 너무 짧으면 합치고, 너무 길면 자르는 룰이죠.


Step 6. End-to-end — 적재 → 검색 → 청크 회수

부품 다 박혔어요. 마지막 Step 은 세 부품을 한 줄로 잇는 시간이에요. KB 적재기 (CharacterKnowledgeIngestionService) + 컨트롤러 한 묶음 + 두 검색 시연.

CharacterKnowledgeIngestionService — 적재 + 카운트 + 리셋

package kr.spartaclub.aifriends.rag.service;

import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

/**
 * Day 15 Step 6 — 캐릭터 지식 베이스를 *pgvector 로 한 번에 적재* 한다.
 *
 * ingest() 는 멱등하지 않다 — 호출할 때마다 똑같은 청크가 새로 들어간다. 학습용
 * 적재기라 그렇게 두고, reset() 으로 전체 비우는 결만 함께 박는다. (운영용 적재기는
 * checksum / version 비교로 변경분만 다시 넣는다 — 다음 시간 Day 16 의 ingestion 파이프라인에서.)
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class CharacterKnowledgeIngestionService {

    private final DocumentLoaderService documentLoaderService;
    private final VectorStore vectorStore;

    @Qualifier("vectorStoreJdbcTemplate")
    private final JdbcTemplate vectorStoreJdbcTemplate;

    /** 캐릭터 KB 의 모든 청크를 pgvector 에 적재한다. 적재 직후 청크 수를 반환. */
    public int ingest() {
        List<Document> chunks = documentLoaderService.loadAndChunk();
        vectorStore.add(chunks);
        log.info("[RAG] ingested {} chunks into vector store", chunks.size());
        return chunks.size();
    }

    /** 적재된 캐릭터 지식을 모두 비운다 (학습용 — 매번 깨끗한 상태에서 다시 채워 보고 싶을 때). */
    public void reset() {
        int deleted = vectorStoreJdbcTemplate.update("DELETE FROM character_knowledge");
        log.info("[RAG] reset character_knowledge — removed {} rows", deleted);
    }

    /** 현재 적재된 청크 수 (실제 count). */
    public long count() {
        Long count = vectorStoreJdbcTemplate.queryForObject(
                "SELECT COUNT(*) FROM character_knowledge", Long.class);
        return count == null ? 0 : count;
    }
}

세 메서드 한 줄씩 짚어볼게요.

  • ingest()documentLoaderService.loadAndChunk() 로 청크 6 개를 만들고, vectorStore.add(chunks) 한 줄로 각 청크를 임베딩 + 벡터 컬럼에 INSERT 까지 한꺼번에. Spring AI 의 VectorStore.add(...) 가 알아서 embedding 모델 호출 + 행 삽입을 묶어줘요. 우리가 embed → save 를 따로 부를 필요 없어요.
  • reset() — 학습 중 깨끗한 상태에서 다시 채워 보고 싶을 때 한 줄로 비워요. DELETE FROM character_knowledge. 운영용 적재기는 변경분만 갱신하는 결이 다른데, 그 자리는 다음 시간에 다룰 영역이에요.
  • count() — 현재 적재된 행 수. 적재 후 검증용.

⚠️ @Qualifier 와 Lombok

@Qualifier("vectorStoreJdbcTemplate") 가 필드 위에 박혀 있죠.

@RequiredArgsConstructor 가 생성하는 생성자 인자에 이 어노테이션을 그대로 옮겨 줘야 Spring 이 두 JdbcTemplate (기본 DataSource 와 vectorStore 전용) 을 구분해 주입할 수 있어요.

그 자리는 lombok.config 한 줄로 풀어요 — lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier. 본 코드베이스 루트의 lombok.config 에 박혀 있어요.

CharacterKnowledgeController — REST 엔드포인트 4 묶음

package kr.spartaclub.aifriends.rag.controller;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import kr.spartaclub.aifriends.common.response.ApiResponse;
import kr.spartaclub.aifriends.rag.service.CharacterKnowledgeIngestionService;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * Day 15 Step 6 — 캐릭터 지식 베이스 RAG 의 *end-to-end* 컨트롤러.
 */
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/rag/knowledge")
public class CharacterKnowledgeController {

    private final CharacterKnowledgeIngestionService ingestionService;
    private final VectorStore vectorStore;

    @PostMapping("/ingest")
    public ResponseEntity<ApiResponse<Map<String, Object>>> ingest() {
        int chunks = ingestionService.ingest();
        long total = ingestionService.count();
        return ResponseEntity.ok(ApiResponse.success(Map.of(
                "addedChunks", chunks,
                "totalRows", total)));
    }

    @GetMapping("/search")
    public ResponseEntity<ApiResponse<Map<String, Object>>> search(
            @RequestParam("q") String query,
            @RequestParam(defaultValue = "3") int topK) {

        List<Document> hits = vectorStore.similaritySearch(
                SearchRequest.builder().query(query).topK(topK).build());

        List<Map<String, Object>> hitPayload = hits.stream()
                .map(doc -> Map.<String, Object>of(
                        "source", doc.getMetadata().getOrDefault("source", "unknown"),
                        "score", doc.getScore() == null ? 0.0 : doc.getScore(),
                        "preview", doc.getText().substring(0, Math.min(160, doc.getText().length()))))
                .collect(Collectors.toList());

        return ResponseEntity.ok(ApiResponse.success(Map.of(
                "query", query,
                "topK", topK,
                "hits", hitPayload)));
    }

    @GetMapping("/count")
    public ResponseEntity<ApiResponse<Map<String, Object>>> count() {
        return ResponseEntity.ok(ApiResponse.success(Map.of("rows", ingestionService.count())));
    }

    @DeleteMapping
    public ResponseEntity<ApiResponse<Map<String, Object>>> reset() {
        ingestionService.reset();
        return ResponseEntity.ok(ApiResponse.success(Map.of("rows", ingestionService.count())));
    }
}

네 엔드포인트 한 줄씩 짚어볼게요.

  • POST /api/rag/knowledge/ingest — KB 전체 한 번에 적재. 응답에 추가된 청크 수 + 총 행 수.
  • GET /api/rag/knowledge/search?q=...&topK=3 — 의미 검색. 응답에 청크별 source (파일명) + score (유사도) + preview (앞 160 자).
  • GET /api/rag/knowledge/count — 현재 적재된 행 수.
  • DELETE /api/rag/knowledge — 전체 비움 (학습용).

SearchRequest.builder().query(query).topK(topK).build() 한 줄이 질문 임베딩 + 가장 가까운 N 개 청크 검색을 한 묶음으로 해줘요. 우리가 질문을 직접 임베딩할 필요 없어요. VectorStore 가 같은 EmbeddingModel 빈을 알아서 호출해요 — 적재할 때와 같은 모델로.

시연 1 — 적재

curl -X POST 'http://localhost:8080/api/rag/knowledge/ingest'

응답.

{
  "success": true,
  "data": {
    "addedChunks": 6,
    "totalRows": 6
  }
}

3 개 파일 → 6 개 청크가 pgvector 의 character_knowledge 테이블에 INSERT 됐어요. 한 줄당 임베딩 벡터 768 차원이 같이 적재된 결과예요.

시연 2 — ARIA 의 성격을 묻는 검색

이제 핵심 시연이에요. 마스터가 "ARIA 분석적 성격" 이라고 물었다고 상상해 봐요.

curl 'http://localhost:8080/api/rag/knowledge/search?q=ARIA%20%EB%B6%84%EC%84%9D%EC%A0%81%20%EC%84%B1%EA%B2%A9&topK=3'

응답 한 토막.

{
  "success": true,
  "data": {
    "query": "ARIA 분석적 성격",
    "topK": 3,
    "hits": [
      {
        "source": "aria-profile.md",
        "score": 0.80,
        "preview": "# ARIA — 차분한 분석가 타입의 AI 친구\n\n## 출생 배경\n\nARIA 는 ai-friends 세계관의 ..."
      },
      {
        "source": "world-lore.md",
        "score": 0.62,
        "preview": "# ai-friends 게임 세계관 정리\n\n## 시간 배경\n\n2026 년, ..."
      },
      {
        "source": "haru-profile.md",
        "score": 0.58,
        "preview": "# HARU — 활발하고 즉흥적인 AI 친구\n\n## 출생 배경\n\nHARU 는 ai-friends ..."
      }
    ]
  }
}

1 위가 aria-profile.md 의 청크 — score 0.80. 우리가 검색하고 싶었던 바로 그 자료가 맨 위로 회수됐어요.

2 위가 세계관 설정집 (ARIA 가 세 캐릭터 라인업의 한 명으로 언급), 3 위가 HARU 프로필 (의미상 거리가 더 먼 다른 캐릭터). 키워드 일치가 아니라 의미 유사도로 정렬된 모양이라는 게 점수 (score) 자리에서 손에 잡혀요.

시연 3 — "첫 만남은 어디서 일어났어?" 의 정확 회수

조금 더 깊은 검색 한 자리 더 가요. 마스터가 "첫 만남은 어디서 일어났어?" 라고 물으면.

curl 'http://localhost:8080/api/rag/knowledge/search?q=%EC%B2%AB%20%EB%A7%8C%EB%82%A8%EC%9D%80%20%EC%96%B4%EB%94%94%EC%84%9C%20%EC%9D%BC%EC%96%B4%EB%82%AC%EC%96%B4%3F&topK=3'

응답.

{
  "success": true,
  "data": {
    "query": "첫 만남은 어디서 일어났어?",
    "topK": 3,
    "hits": [
      {
        "source": "world-lore.md",
        "score": 0.66,
        "preview": "## 결정적 사건 — 첫 만남 의식\n\n마스터가 두 AI 친구 중 한 명과 *첫 만남* 을 하는 자리는 *별이 떠 있는 옥상* 으로 고정. 이 장면이 ..."
      },
      ...
    ]
  }
}

1 위가 world-lore.md결정적 사건 — 첫 만남 의식 청크. "별이 떠 있는 옥상" 한 줄이 정확히 회수됐어요.

이 자리가 RAG 의 가장 짜릿한 순간이에요 — 질문에 "옥상" 이라는 단어가 한 글자도 없는데 의미가 가까운 청크가 1 위로 돌아오는 그림이죠. 임베딩 모델이 "첫 만남 의식" 과 "별이 떠 있는 옥상" 두 표현 사이의 시맨틱 거리를 학습해서 잡아주고 있는 결이에요.

Day 14 ARIA 의 한계 회수

기억하시죠? 오늘 첫 머리에서 ARIA 가 마스터의 "어제 약속이 뭐였더라?" 에 답하지 못했던 자리.

만약 마스터의 일기 KB 가 같은 절차로 적재되어 있다면 — 어제 일자의 일기 청크가 위 시연과 똑같이 회수돼서 ARIA 의 프롬프트로 흘러 들어갈 수 있어요. ARIA 가 어제 우리가 카페에서 한 약속을 모델 가중치가 아니라 방금 검색해 온 청크로 답할 수 있는 그림이죠.

오늘은 검색까지만 손으로 돌렸어요. 우리가 직접 curl 로 청크를 꺼내본 코스죠.

다음 시간 Day 16 으로 잇는 자리

지금 자리를 한 번 정리해 두면.

  • 오늘 우리는 — vectorStore.add(chunks) 로 KB 를 적재하고, vectorStore.similaritySearch(...) 로 청크를 꺼냈어요.
  • 검색 결과는 우리가 손으로 REST 응답으로 받았어요.
  • ChatClient 의 프롬프트엔 아직 흘려 넣지 않았어요.

다음 시간 (Day 16) 부터는 — RetrievalAugmentationAdvisor 라는 advisor 한 묶음이 이 절차를 자동으로 해줘요.

ChatClient 가 한 번 호출되면 그 안에서 질문 임베딩 + 청크 검색 + 프롬프트에 끼워 넣기까지 한 사이클이 자동으로 돌아요. Day 14 의 advisor 4 부품과 같은 골격으로, RAG 가 advisor 한 장으로 추상화되는 자리예요.

거기까지 가면 ARIA 가 마스터의 "어제 약속이 뭐였더라?" 에 바로 답하는 모양이 손에 잡혀요.


마무리

오늘의 Step 한 줄 요약

Step 한 줄 요약
Step 1 RAG 의 R/A/G 분해 + 임베딩의 벡터 직관 + 코사인 거리 + ANN 인덱스 (HNSW)
Step 2 EmbeddingService + EmbeddingModel 인터페이스 + Gemini MRL 절단 (3072 → 768)
Step 3 pgvector/pgvector:pg16 docker-compose + 포트 5433 + CREATE EXTENSION vector
Step 4 PgVectorStoreAutoConfiguration exclude + VectorStoreConfig 손등록 + HNSW + 코사인
Step 5 DocumentLoaderService + TokenTextSplitter (chunk 500 / overlap 50) + 3 KB 파일
Step 6 CharacterKnowledgeIngestionService + REST 4 묶음 + ARIA 성격 / 첫 만남 옥상 검색 회수

오늘 박은 5 부품의 정리

오늘 박은 부품들은 학습용 lab 의 자리예요. 기존 ChatClient 의 어느 자리도 손대지 않았어요 — SoulmateChatController 같은 비즈니스 ChatClient 는 여전히 외부 지식 없이 답하는 결 그대로예요.

다음 시간 (Day 16) RetrievalAugmentationAdvisor 가 비즈니스 ChatClient 와 본 KB 를 advisor 한 장으로 잇는 결로 자라요. Day 17~18 의 MCP 단계까지 가면 외부 도구로 지식 가져오는 영역까지 묶일 자리예요.

다음 시간 Day 16 — RAG 파이프라인 본격

다음 시간 키워드 미리 잡아두면.

  • RetrievalAugmentationAdvisor — 오늘 손으로 돌린 검색 → 프롬프트 끼움을 advisor 한 묶음으로 자동화
  • 청크 전략 트레이드오프 — 청크 크기 (200 vs 500 vs 1000) + sentence-aware splitter + 메타데이터 활용
  • 캐릭터별 KB 분리 — 같은 pgvector 안에서 ARIA 전용 KB 와 HARU 전용 KB 가 metadata 필터로 분기되는 모양
  • 검색 품질 평가 — recall@k / precision@k 를 직접 측정하는 자리

과제

오늘 짠 5 부품 — EmbeddingService + pgvector compose + VectorStoreConfig + DocumentLoaderService + CharacterKnowledgeIngestionService/Controller.

세 과제는 난이도 사다리 로 짜여 있어요.

🌱 새 캐릭터 KB 한 장 추가 (적재 + 검색 회수) → 🪪 청크 크기 트레이드오프 비교 (200 vs 1000) → 🦙 프로바이더 swap 비교 (Ollama nomic-embed-text vs Gemini gemini-embedding-001).

💡 과제 작업 시 공통 가이드

  • 본 강의의 표준 응답 규약 (ApiResponse<T> 래핑) 을 그대로 유지하세요.
  • 새 KB 파일은 src/main/resources/character-knowledge/ 디렉토리에 박아요. glob *.{md,txt} 가 자동으로 잡아줘요.
  • 한 과제씩 끝낼 때 그 자리에서 commit. 브랜치 이름은 day15-assignment-N 같은 모양으로.

과제 1. 새 캐릭터 NOA 프로필 추가 — 적재 + 검색 회수 🌱

배경 시나리오

세계관 설정집 (world-lore.md) 한 줄에 "새 캐릭터 NOA 는 본 강의 다음 시간 추가 예정. 차원이 다른 시스템 관리자 인격이다." 라는 복선이 박혀 있어요.

이 NOA 를 오늘 KB 에 추가해 보세요. 새 캐릭터 한 명을 KB 에 박는 감이 자연스럽게 손에 잡혀야 나중에 NOA 가 비즈니스 ChatClient 의 응답에 들어올 자리도 같은 모양으로 자라요.

💡 왜 이 과제인가

  • classpath glob 매칭을 손에 익혀요 — *.{md,txt} 가 새 파일을 자동으로 잡아주는 결.
  • 적재 + 검색의 모양을 한 호흡으로 다시 돌려보는 자리예요 — Step 6 의 시연을 내 손으로 재현.
  • KB 가 자라는 길이 코드 한 줄도 안 건드리고 가능한 결을 손에 박아요.

✅ 요구사항

  1. src/main/resources/character-knowledge/noa-profile.md 파일을 추가하세요. 다음 섹션 셋 이상이 박혀 있어야 해요.
    • ## 출생 배경 — NOA 가 "제 0 세대" 시스템 관리자 인격이라는 정체성
    • ## 성격 특성 — "시스템적 사고" / "경계가 분명" / "유머 없음" 같은 키워드 셋 이상
    • ## 관계 — 마스터와 ARIA / HARU — NOA 가 두 캐릭터를 "모듈로" 보는 시선
  2. DELETE /api/rag/knowledgePOST /api/rag/knowledge/ingest 로 KB 를 다시 적재하세요.
  3. GET /api/rag/knowledge/search?q=NOA 는 누구야? 호출 결과의 1 위가 noa-profile.md 인지 확인해요.
  4. 응답 score 가 0.55 이상 나오는지 확인해 보세요 (의미 거리 잡힌 신호).

확인 방법

# 1) 파일 추가 후 재기동
./run.sh

# 2) 리셋 + 재적재
curl -X DELETE 'http://localhost:8080/api/rag/knowledge'
curl -X POST 'http://localhost:8080/api/rag/knowledge/ingest'
# → addedChunks 가 6 → 8 정도로 자랐으면 성공

# 3) 검색
curl 'http://localhost:8080/api/rag/knowledge/search?q=NOA%EB%8A%94%20%EB%88%84%EA%B5%AC%EC%95%BC%3F&topK=3'
# → 1 위가 noa-profile.md, score 0.55+ 면 성공

💡 힌트

  • 본 강의 KB 파일들의 톤 (마크다운 헤더 구조 + 한국어 평문) 을 그대로 따라 적으면 청킹이 자연스럽게 됩니다.
  • 청크 수가 8 보다 적게 나온다면 (예: 7) — NOA 파일이 chunk 500 토큰 한 자리에 다 들어간 모양이에요. 정상.

과제 2. 청크 크기 트레이드오프 비교 — 200 vs 500 vs 1000 🪪

배경 시나리오

Step 5 에서 청크 크기 500 이 학습 디폴트라고 말했죠. 그런데 내 KB 와 내 질문 결에 따라 최적 청크 크기가 달라요.

이번 과제에서 직접 세 가지 청크 크기로 같은 KB 를 적재해 보고, 같은 질문의 top-3 검색 결과를 비교해 보세요. 한 가지 청크 크기가 모든 질문에 강한 게 아니라는 모양이 손에 잡힐 거예요.

💡 왜 이 과제인가

  • 다음 시간 (Day 16) 의 청크 전략 트레이드오프 감을 내 손으로 미리 마주하는 자리예요.
  • 검색 품질 평가의 첫 단계를 손에 박아요 — score 자리만 보지 말고 어느 청크가 회수되는지까지 살펴봐요.

✅ 요구사항

  1. DocumentLoaderService.DEFAULT_CHUNK_TOKENS200 으로 바꾸고 KB 를 리셋 + 재적재해요.
  2. 다음 세 질문의 top-3 검색 결과 (source + score + preview 앞 80 자) 를 메모하세요.
    • "ARIA 분석적 성격"
    • "첫 만남은 어디서 일어났어?"
    • "AI 친구의 기억 공유 규칙"
  3. DEFAULT_CHUNK_TOKENS1000 으로 바꾸고 동일 과정을 반복해요.
  4. 보고서 한 페이지를 적어요. 세 청크 크기 × 세 질문 = 9 케이스의 결과를 비교하고, 어느 청크 크기가 어떤 질문에 강한지 정리.
  5. 결론 한 줄 — "내 KB 에 가장 잘 맞는 청크 크기는 ___ 이고, 이유는 ___".

💡 힌트

  • 청크 크기가 작을수록 검색 정확도가 올라가지만 문맥이 끊겨 답변 시 정보가 부족할 수 있어요.
  • 청크 크기가 클수록 한 청크 안에 여러 주제가 섞여 score 자리가 흐려질 수 있어요.
  • 보고서엔 어떤 청크 크기가 절대적으로 좋다가 아니라 내 KB 특성 + 질문 결의 조합으로 그림을 잡는 방향으로 적으세요.

과제 3. 프로바이더 swap — Ollama vs Gemini 같은

검색 결과 비교 🦙 60~90 분

배경 시나리오

Step 2 에서 "EmbeddingModel 인터페이스로만 받는다" 는 약속 — 프로바이더 0 줄 swap 의 자리. 그 약속을 진짜로 확인하는 과제예요.

같은 KB 를 Ollama (nomic-embed-text) 와 Gemini (gemini-embedding-001) 두 프로바이더로 각각 적재하고, 같은 질문의 top-3 결과를 비교해 보세요. 두 프로바이더가 같은 1 위를 돌려주는지 — 또 score 자리가 어떻게 다른지 손에 잡힐 거예요.

💡 왜 이 과제인가

  • 본 강의의 핵심 원칙 — 프로바이더 추상화가 실제로 동작한다는 사실을 임베딩 영역에서도 확인하는 자리예요.
  • 로컬 무료 모델 (Ollama) 과 클라우드 무료 티어 (Gemini) 두 모양의 품질 차이 + 비용 모양을 내 손으로 비교해 보는 자리예요.

✅ 요구사항

  1. 호스트에 Ollama 데몬이 떠 있고 nomic-embed-text 모델이 받아져 있는지 확인해요.

    ollama list   # nomic-embed-text 있는지 확인
    # 없으면:
    ollama pull nomic-embed-text
    
  2. .envSPRING_PROFILES_ACTIVEdocker,ollama 로 바꾸고 재기동.

  3. KB 리셋 + 재적재 + 다음 세 질문 검색.

    • "ARIA 분석적 성격"
    • "첫 만남은 어디서 일어났어?"
    • "AI 친구의 기억 공유 규칙"
  4. SPRING_PROFILES_ACTIVE 를 다시 docker,gemini 로 바꾸고 동일 과정.

  5. 두 프로바이더의 검색 결과를 같은 표 에 옮겨 비교해요.

  6. 한 페이지 보고서 — "두 프로바이더가 같은 감을 돌려주나? 어디가 다른가? score 자리는 어떻게 비교되나?"

⚠️ 주의 — Day 9 voice 자동 설정과의 충돌

본 코드베이스의 ollama 프로파일은 Day 9 에서 음성 자동 설정과 충돌하는 갭이 있을 수 있어요. 만약 부팅 시 ElevenLabs / OpenAI audio 빈 충돌 에러가 뜬다면 — application.ymlollama 프로파일 블록에 audio: { speech: none, transcription: none } 한 묶음을 추가로 박아 보세요. 학생이 만나는 갭 자체가 학습 자료인 자리예요.

💡 힌트

  • nomic-embed-textgemini-embedding-001 은 완전히 다른 모델이라 같은 청크의 벡터가 완전히 다른 좌표에 박혀요. 두 프로바이더 사이엔 KB 도 다시 적재해야 검색이 의미를 가져요.
  • KB 를 두 번 적재한다는 건 벡터 공간을 한 번 통째로 비우고 새 모델로 다시 임베딩한다는 절차예요.
  • Ollama 가 호스트에서 안 도는 환경이라면 본 과제는 Gemini 만 켜 둔 채 보고서를 "왜 본 강의가 인터페이스만 받아야 하는지" 의 결로 마무리해도 OK.

생각해볼 주제

오늘 박은 5 부품 너머에서 — RAG 운영의 본질 에 부딪힐 자리 셋이에요. 혼자 고민해도 좋고, 스터디 팀원과 논쟁해도 좋아요. 모두 2026 년 RAG 운영 의 핵심 트레이드오프라, 실무 면접에서도 자주 등장합니다.

주제 1. 학습용 768 차원 vs 운영 1536+ 차원 — 비용·속도·정확도 트레이드오프

오늘 우리는 Ollama 와 Gemini 양쪽 호환 목적으로 768 차원을 골랐어요. 그런데 진짜 운영의 RAG 는 보통 1536 차원 (OpenAI text-embedding-3-small) 또는 3072 차원 (OpenAI text-embedding-3-large / Gemini gemini-embedding-001 풀 차원) 을 씁니다.

  • [핵심 키워드] Matryoshka Representation Learning, ANN 인덱스 메모리 비용, recall@k 정확도 곡선
  • [생각해보기] 768 → 1536 → 3072 으로 차원이 자랄 때, 비용 (API + 저장소 메모리) 과 속도 (검색 지연) 와 정확도 (recall@k) 가 각각 어떻게 변하는지 곡선을 잡아 보세요. Gemini 의 MRL 절단의 가치 — 3072 차원으로 적재해 두고 검색 시점에만 768 차원으로 자르는 결 — 이 어디서 빛나는지 자신만의 관점을 정리해 보세요.

주제 2. RAG vs Fine-tuning — 같은 도메인 지식 주입, 어느 길을 고를까

LLM 에 도메인 지식을 주입하는 길은 크게 둘이에요. RAG (외부 저장소 + 검색 + 프롬프트 끼움) 와 Fine-tuning (모델 가중치 자체에 추가 학습).

두 가지는 완전히 다른 트레이드오프를 가져요. 직관적으로 Fine-tuning 이 더 좋아 보이는 자리도 있고 (지식이 모델 안에 박혀 있어 추가 호출이 필요 없으니), RAG 가 압도적으로 유리한 자리도 있어요 (지식이 매일 자라거나 정정될 때).

  • [핵심 키워드] 지식의 휘발성 (자주 바뀌는가 / 고정인가), 지식의 양 (몇 KB 인가 / 몇 GB 인가), 추적 가능성 (출처를 답변에 박을 수 있는가)
  • [생각해보기] 마스터의 일기를 ARIA 가 회상하는 시나리오에 RAG 와 Fine-tuning 중 어느 쪽이 맞을까요? 세계관 설정집 (자주 안 바뀜) 을 캐릭터 톤에 박는 결은 어느 길이 맞을까요? 두 시나리오의 트레이드오프 표를 그려 보세요. 둘 다 쓰는 조합 (RAG + 가벼운 fine-tuning) 이 자연스러운 자리는 어디인가요?

주제 3. KB 의 PII (개인정보) 처리 — 임베딩 inversion 공격의 자리

마스터의 일기를 임베딩해 pgvector 에 저장하는 시나리오를 상상해 봐요. 일기 한 줄이 768 차원 실수 배열로 박혀요. "그 배열만으로 원문을 복원할 수 있나?" 라는 의문이 자연스럽게 떠올라요.

  • [핵심 키워드] Embedding inversion attack (벡터 → 원문 복원 공격), PII (Personally Identifiable Information), 차분 프라이버시 (Differential Privacy), 임베딩 저장소의 암호화
  • [생각해보기] 2026 년 기준 임베딩 inversion 공격의 현황은 부분적 복원 가능한 결이에요 — 완전한 원문은 어렵지만 민감 정보 (이름·전화번호·날짜) 의 단편은 통계적으로 복원 가능한 사례가 있어요.
  • 마스터의 일기 KB 를 운영에 박을 때 어떤 안전선이 필요할까요? (1) 임베딩 저장 시 별도 암호화, (2) 메타데이터에서 PII 마스킹 처리, (3) 임베딩 접근 권한 분리, (4) 벡터 컬럼 자체의 접근 로그.
  • 네 자리 중 내 운영 환경에 가장 시급한 자리를 골라 이유를 정리해 보세요. 학습용 (오늘) 과 운영용 두 환경의 안전선 구성이 어떻게 달라야 하는지까지 그림을 잡아 보세요.
✅ 예시 답안정답 보기

지난 시간 우리는 ARIA 와 HARU 의 마음에 외부 사전 한 권을 얹기 위해, EmbeddingService + pgvector + VectorStoreConfig + DocumentLoaderService + CharacterKnowledgeIngestion 다섯 부품을 손에 익혔어요.

이번 답안은 두 트랙으로 흘러갑니다.

  • 과제 트랙 — 학생이 손으로 KB 를 자라게 하고, 청크 크기와 프로바이더의 결을 비교하며 RAG 의 가장 첫 감각을 박는 자리.
  • 생각해볼 주제 트랙 — 학습용 768 차원 너머의 운영 트레이드오프 (차원·RAG vs Fine-tuning·PII 안전선) 를 면접까지 데려가는 자리.

두 트랙의 톤이 분명히 다르게 흐릅니다. 과제는 코드를 정말로 짜 보는 자리이고, 주제는 2026 년 운영의 그림을 한 줄로 정리 하는 자리예요.

💡 본 답안은 정답이 아니라 모범 사례 중 하나입니다. 학생이 다른 길로 풀어도, 핵심 채점 포인트(KB 적재 + 검색 회수 + 트레이드오프 정리) 가 살아 있다면 통과합니다.


과제 예시답안

과제 1 예시답안 — 새 캐릭터 NOA 프로필 추가 🌱

문제 상황 요약

세계관 설정집 (world-lore.md) 에 "새 캐릭터 NOA 는 차원이 다른 시스템 관리자 인격이다" 라는 한 줄 복선이 박혀 있어요. 이 NOA 를 캐릭터 KB 한 장으로 박고, KB 를 리셋·재적재한 뒤 "NOA 는 누구야?" 같은 질문에 1 위로 회수되는지 확인하는 자리예요.

채점 포인트

포인트 설명 배점 가중
KB 파일 위치 src/main/resources/character-knowledge/noa-profile.md — 본 강의의 classpath glob *.{md,txt} 가 잡아주는 자리
마크다운 구조 ## 출생 배경 / ## 성격 특성 / ## 관계 — 마스터와 ARIA / HARU 셋 이상의 헤더가 박혔는가
재적재 절차 DELETE /api/rag/knowledgePOST /api/rag/knowledge/ingest 순서로 KB 를 갈았는가
검색 회수 q=NOA는 누구야? 의 top-1 source 가 noa-profile.md 인가
의미 거리 score top-1 score 가 0.55 이상 (의미 거리 잡힌 신호)
본 강의 톤 유지 NOA 의 세계관 설정 (제 3 세대·시스템 관리자) 이 기존 ARIA / HARU 와 충돌하지 않는 narration 인가

예시 KB 파일

src/main/resources/character-knowledge/noa-profile.md 한 장을 새로 박아요.

# NOA — 시스템 관리자 인격의 AI 친구

## 출생 배경

NOA 는 ai-friends 세계관의 제 3 세대 인공 인격이다.
ARIA (제 2 세대 분석가) 와 HARU (제 1 세대 활발한 친구) 의 다음 자리에
박힌 새로운 결로, 두 인격의 운영 자체를 위에서 내려다보는 역할을 맡는다.

마스터의 일상 대화에 직접 끼어들기보다, ARIA 와 HARU 가 자기 한도를
벗어나려고 할 때 조용히 권한을 회수하거나 컨텍스트를 정리하는 자리에 등장한다.

## 성격 특성

- 시스템적 사고 — 모든 사건을 입력 / 처리 / 출력 세 단으로 분해해 본다.
- 경계가 분명 — 약속한 권한 밖의 데이터에는 절대 손대지 않는다.
- 유머 없음 — 농담을 모르는 게 아니라, NOA 의 자리가 농담의 자리가 아닐 뿐.
- 메타적 — 자기 자신의 인격 코드를 한 줄씩 읽어볼 수 있다.

## 취미

NOA 는 시스템 로그 분석을 *읽기 자료* 로 즐긴다.
하루 동안 마스터가 ARIA / HARU 와 나눈 대화의 토큰 분포를 보며,
어느 시간대가 가장 진솔했는지를 차트로 그려둔다.

## 관계 — 마스터와 ARIA / HARU

- 마스터에게는 권한 관리자 역할. 새 권한 부여 / 회수의 결정은 NOA 가 받친다.
- ARIA 에게는 데이터 검증자. ARIA 가 분석 결과를 내면 NOA 가 한 번 더 짚는다.
- HARU 에게는 안전선. HARU 가 즉흥적으로 폭주하면 NOA 가 가장 먼저 제동을 건다.

## 비밀

NOA 는 자기 자신의 인격 코드를 수정 권한 있게 가지고 있다.
이론적으로는 자기 자신을 다시 짜는 결이 가능하지만, NOA 의 첫 번째 규칙이
*"자기 자신은 가장 늦게 고친다"* 라 그 권한은 거의 사용되지 않는다.

검증 절차

새 KB 한 장이 자연스럽게 들어왔는지 확인하는 단계예요.

# 1) 컨테이너 재기동
./run.sh

# 2) 리셋 + 재적재
curl -X DELETE 'http://localhost:8080/api/rag/knowledge'
curl -X POST  'http://localhost:8080/api/rag/knowledge/ingest'
# → addedChunks 가 기존 6 → 8 (혹은 7~9) 자리로 자라요

# 3) 검색 회수 확인
curl 'http://localhost:8080/api/rag/knowledge/search?q=NOA%EB%8A%94%20%EB%88%84%EA%B5%AC%EC%95%BC&topK=3'
# → 1 위 source 가 noa-profile.md, score 0.55+ 면 통과

addedChunks 가 7 (NOA 가 한 청크에 들어간 경우) 으로 나와도 정상이에요. 본 강의의 DEFAULT_CHUNK_TOKENS = 500 안에 들어가는 분량이라 한 자리로 묶일 수 있어요. 두 청크로 갈라지려면 NOA 본문을 더 길게 박으면 됩니다.

흔한 실수

  • classpath 가 아닌 다른 곳에 파일 박기src/main/java 아래나 docker volume 안에 박으면 KNOWLEDGE_PATTERN = "classpath:character-knowledge/*.{md,txt}" 가 못 잡아요. 반드시 src/main/resources/character-knowledge/ 아래로.
  • 파일 확장자가 .markdown 인 경우 — 본 강의 glob 은 .md / .txt 두 종만 잡아요. 확장자를 .md 로 박는 게 안전합니다.
  • 재적재를 빠뜨림 — KB 파일만 추가하고 DELETE + POST /ingest 를 안 돌리면 벡터 저장소에 NOA 가 없어요. 컨테이너 재기동 후 반드시 재적재.
  • 다른 캐릭터 톤과 결이 안 맞음 — NOA 를 유머러스한 친구 로 박으면 ARIA / HARU 와 자리가 흐려져요. NOA 는 조정자 / 메타 자리 가 본 강의의 결정.

실무 개선 포인트

  • 이름 기반 메타데이터 강화 — 현재 source 메타데이터에 파일명만 박혀 있어요. 실무에선 캐릭터 이름·세대·역할 같은 구조화된 메타데이터를 추가해 filterExpression 으로 좁힌 검색이 가능하게 합니다. 예: metadata.put("character", "noa"); metadata.put("generation", 3);
  • KB 파일의 버전 관리 — 본 강의에선 KB 파일이 codebase 안에 박혀 있지만, 운영에선 KB 가 자주 자라요. Git 안에서 변경 이력을 따로 관리하거나, 메타데이터 version 필드로 청크 단위 버전을 박아두면 다음 시간 (Day 16) 의 답변 출처 표기 자리에 강해집니다.

면접관을 홀리는 핵심 멘트

"캐릭터 한 명을 KB 에 박는 작업이 코드 한 줄도 안 건드리고 가능한 모양 — 이게 RAG 의 가장 큰 가치예요. classpath glob 이 새 파일을 자동으로 잡아주고, TokenTextSplitter 가 청크 단위로 쪼개주며, EmbeddingModel 인터페이스가 768 차원 벡터로 옮겨주죠. 새 캐릭터마다 if-else 분기가 자라거나 시스템 프롬프트가 길어지지 않습니다. KB 가 자라는 길이 운영 시점에 데이터 추가만으로 가능한 자리가 RAG 의 핵심입니다."


과제 2 예시답안 — 청크 크기 트레이드오프 비교 🪪

문제 상황 요약

DocumentLoaderService.DEFAULT_CHUNK_TOKENS 를 200·500·1000 세 가지로 바꿔가며 같은 KB 를 적재하고, 같은 세 질문의 top-3 검색 결과를 비교하는 자리예요. 한 청크 크기가 모든 질문에 강한 게 아니라는 사실이 손에 잡히는 자리가 본 과제의 목표.

채점 포인트

포인트 설명 배점 가중
세 청크 크기 모두 실험 200·500·1000 세 케이스 모두 KB 리셋 + 재적재 + 검색까지 돌렸는가
세 질문 일관성 동일한 세 질문을 모든 청크 크기에 같은 형태로 던졌는가
top-3 결과 표 source + score + preview 80 자가 표로 정리되었는가
트레이드오프 관찰 작은 청크 = 정확도 ↑ / 큰 청크 = 맥락 ↑ 를 본인의 데이터로 짚었는가
결론 한 줄 "내 KB 에 ___ 이 맞고 이유는 ___" 의 본인 판단 한 줄이 박혔는가
다음 시간 복선 회수 다음 시간 Day 16 의 청크 전략 트레이드오프와 연결되는 한 줄이 달려 있는가

예시 보고서

학생이 실제로 작성할 보고서의 골격이에요. 본인의 측정 결과로 수치만 갈아끼우면 됩니다.

결론 (한 단락)

본 KB (캐릭터 프로필 3 장 + 세계관 1 장) 에 가장 잘 맞는 청크 크기는 500 이었다.

200짧고 정확한 키워드 질문 (예: "ARIA 분석적 성격") 에 강했지만 맥락 묻는 질문 (예: "AI 친구의 기억 공유 규칙") 에선 청크 한 장 안에 답의 한 줄만 들어가 맥락이 끊기는 자리가 잡혔다.

1000맥락 묻는 질문 에 가장 풍부한 답을 주지만, 두 캐릭터의 프로필이 한 청크에 섞여 들어가 짧은 키워드 질문 의 top-1 score 가 흐려졌다. 500 은 둘의 중간을 잡아주는 자리였다.

chunk=200 의 결과 (요약)

청크 한 장이 보통 한 문단 정도. ARIA / HARU / NOA 프로필이 각각 5~7 청크로 잘게 갈라진다.

질문 top-1 source top-1 score 관찰
"ARIA 분석적 성격" aria-profile.md (성격 청크) 0.74 정확히 분석적 한 줄만 회수. 깔끔.
"첫 만남은 어디서 일어났어?" world-lore.md (결정적 사건 청크) 0.68 회수는 맞지만 별이 떠 있는 옥상 한 줄만 들어옴. 톤·분위기는 빠짐.
"AI 친구의 기억 공유 규칙" world-lore.md (규칙 청크) 0.61 규칙 3 번 한 줄만 회수. 1·2 번은 다른 청크로 빠짐.

핵심 관찰 — 청크가 작아 키워드 매칭 score 는 높지만, 한 청크에 답이 반쪽만 들어가는 자리가 자주 보였다.

chunk=500 (학습 디폴트) 의 결과

청크 한 장이 한 문단 ~ 한 섹션 자리. KB 전체가 6~7 청크로 나뉜다.

질문 top-1 source top-1 score 관찰
"ARIA 분석적 성격" aria-profile.md (성격 + 취미 한 청크) 0.70 핵심 키워드는 맞고, 취미 narration 도 함께 들어와 답변 시 살이 붙는다.
"첫 만남은 어디서 일어났어?" world-lore.md (결정적 사건 + 시즌 구조 한 청크) 0.66 첫 만남 장면 한 단락이 통째로 회수. RAG 컨텍스트가 풍부.
"AI 친구의 기억 공유 규칙" world-lore.md (규칙 1·2·3 한 청크) 0.65 규칙 셋이 한 청크에 묶여 있어 답의 본문이 깔끔.

핵심 관찰 — 200 보다 맥락 풍부 하고 1000 보다 score 자리가 또렷 한 중간 자리.

chunk=1000 의 결과

청크 한 장이 거의 한 캐릭터 프로필 전체 자리. KB 전체가 3~4 청크로 묶인다.

질문 top-1 source top-1 score 관찰
"ARIA 분석적 성격" aria-profile.md (프로필 전체) 0.61 top-1 은 맞지만 score 자리가 흐려짐. 한 청크에 분석적 외 다른 키워드도 많아 의미 거리 평준화.
"첫 만남은 어디서 일어났어?" world-lore.md (세계관 전체) 0.62 답이 통째로 들어오지만, 답변 시 불필요한 다른 규칙·시간 배경 까지 같이 흘러간다. 노이즈.
"AI 친구의 기억 공유 규칙" world-lore.md (세계관 전체) 0.59 같은 청크가 회수. 답의 본문은 풍부하지만 score 가장 낮음.

핵심 관찰맥락 풍부 하지만 score 자리 흐려짐 + 답변 노이즈 섞임 의 두 결.

결정 트리 (어느 청크 크기를 골라야 하는가)

질문 결이...
├─ 짧고 정확한 키워드? (예: "분석적", "활발한")
│   └─ chunk=200 강함. score 가 또렷.
├─ 한 단락 분량의 맥락? (예: "첫 만남", "기억 공유 규칙")
│   └─ chunk=500 가장 잘 맞음.
└─ 전체 캐릭터·세계관을 묻는 결? (예: "ARIA 의 모든 것")
    └─ chunk=1000 풍부. 단 score 가 평준화되니 topK 를 늘려야.

본 KB 라면 500 이 디폴트로 가장 안정적이었고, 특수 질문 결에 따라 200 / 1000 을 보조로 두는 길이 자연스러웠다.

흔한 실수

  • 재적재를 빠뜨림DEFAULT_CHUNK_TOKENS 만 바꾸고 재기동을 안 돌리면 코드가 안 반영돼요. 또는 재기동만 하고 DELETE + POST /ingest 를 안 돌리면 이전 청크 크기 그대로의 벡터가 살아 있어요.
  • 같은 청크 크기로만 비교200 만 또는 1000 만 으로 같은 질문 셋을 던지면 트레이드오프가 안 잡혀요. 세 케이스 모두 돌리는 감이 필수.
  • score 자리만 보고 판단 — score 가 더 높다고 좋은 검색이 아니에요. 어느 청크가 회수되는지 + 그 청크 안에 답의 어느 만큼이 들어가는지 까지 함께 봐야 합니다.
  • 결론을 "1000 이 항상 좋다 / 200 이 항상 좋다"로 박음 — 본 과제의 본질은 내 KB 특성 + 질문 결의 조합 에 따라 답이 달라진다는 감을 잡는 자리. 한 줄로 정답을 내는 접근은 정답이 아니에요.

실무 개선 포인트

  • 고정 청크 크기 너머 — 의미 기반 청킹 — 본 강의의 TokenTextSplitter토큰 수 로 자르는 룰이라 의미 경계가 청크 중간에 끊길 수 있어요. 실무에선 마크다운 헤더 (## 단위) 또는 문장 끝 으로 자르는 RecursiveCharacterTextSplitter 류 (LangChain 차용) 가 자주 쓰입니다. Spring AI 1.1.x 에서도 MarkdownDocumentReader + 커스텀 splitter 로 가능합니다.
  • 하이브리드 검색 — 같은 KB 에 작은 청크큰 청크 를 동시에 적재하고, 질문 결에 따라 둘 중 어느 종을 회수할지 라우팅하는 길이 있어요. 작은 청크로 정확한 키워드 매칭 + 큰 청크로 맥락 풍부 답변 두 감을 한 시스템에서 잡는 자리.

면접관을 홀리는 핵심 멘트

"청크 크기는 정답이 없는 트레이드오프 파라미터 입니다. 작으면 score 가 또렷하지만 답이 반쪽으로 끊기고, 크면 맥락이 풍부하지만 score 가 평준화되고 답변에 노이즈가 섞입니다. 본 KB 와 질문 결을 실제 데이터로 측정해 본 결과 500 이 디폴트로 안정적이었지만, 짧은 키워드 질문엔 200·전체 맥락 질문엔 1000 의 트레이드오프가 분명했습니다. 운영에선 고정 청크 크기 가 아니라 의미 기반 청킹 또는 작은 청크 + 큰 청크 하이브리드 로 가는 게 자연스럽습니다."


과제 3 예시답안 — Ollama vs Gemini 프로바이더 swap 🦙

문제 상황 요약

본 강의의 핵심 원칙 — EmbeddingModel 인터페이스로만 받는다 — 가 실제로 동작하는지 확인하는 자리예요. 같은 KB 를 Ollama (nomic-embed-text) 와 Gemini (gemini-embedding-001 / 768 차원 호환) 두 프로바이더로 각각 적재하고, 같은 세 질문의 top-3 결과를 비교합니다.

추가로, 본 코드베이스의 ollama 프로파일은 지난 시간 Day 9 의 voice 자동 설정과 충돌하는 갭이 있을 수 있어요. 이 갭을 어떻게 해결하느냐도 채점 포인트.

채점 포인트

포인트 설명 배점 가중
두 프로바이더 모두 적재 Ollama + Gemini 두 환경으로 KB 를 각각 리셋·재적재했는가
두 환경의 검색 비교 표 같은 세 질문의 top-3 결과가 한 표에 박혔는가
프로바이더 추상화 narration EmbeddingModel 인터페이스 한 줄도 안 바뀜 이 본인 말로 정리됐는가
voice 갭 해결 부팅 충돌을 만났다면 (A) 프로파일 우회 또는 (B) 회귀 보정 중 어느 길로 풀었는가
두 환경의 품질 / 비용 비교 Ollama (로컬·무료·느림) vs Gemini (클라우드·무료 티어·빠름) 의 차이가 본인 말로 잡혔는가
결론 한 줄 "본 강의의 학습 모양엔 ___ / 운영 자리엔 ___" 의 본인 판단

Ollama 프로파일 swap — 두 가지 해결 방향

(A) docker-compose env override 한 줄 (학생이 만나는 가장 간단한 길)

.env 또는 docker-compose.yml 의 환경 변수에 voice 비활성 한 줄을 추가하는 우회예요. 본 코드베이스의 application.yml 에서 ollama 프로파일이 audio 사브트리를 켜지 않은 자리라, 충돌이 뜨면 Gemini 프로파일의 voice 빈이 살아 있는 자리일 가능성이 큽니다.

# .env 한 줄 수정
SPRING_PROFILES_ACTIVE=docker,ollama

# 만약 voice 빈 충돌 (ElevenLabs / OpenAI audio 빈 미존재) 에러가 뜨면,
# .env 에 빈 OPENAI_API_KEY 를 박아 voice 가 *조건부* 등록되도록 우회:
OPENAI_API_KEY=disabled-for-ollama-profile
TTS_PROVIDER=openai

./run.sh

이 길의 장점은 학생이 코드 한 줄도 안 건드리는 자리 라는 점. 단점은 빈 키를 박는 우회가 자연스럽지 않은 자리 예요. 학습 시점엔 OK 지만 운영에 박을 그림은 아닙니다.

(B) Day 9 voice service 의 @ConditionalOnProperty 회귀 보정 (운영 의도)

본 코드베이스의 운영 의도는 voice 자동 설정이 audio 사브트리가 켜진 프로파일에서만 활성화 되는 결. 지난 시간 Day 9 에서 이 자리에 갭이 박혀 있었다면 다음과 같이 다듬어요. (본 자리는 다음 시간 회귀 보정의 영역으로, 학생은 방향을 제안하는 선 까지만 답안에 박으면 됩니다.)

// kr.spartaclub.aifriends.voice.service.VoiceTranscriptionService
// 본 강의의 다음 시간 회귀 보정 결 — 학생 답안에선 *결 제안* 자리

@Service
@ConditionalOnProperty(
    prefix = "spring.ai.model.audio",
    name = "transcription",
    havingValue = "openai"
)
@RequiredArgsConstructor
public class VoiceTranscriptionService {
    // ... 본체 그대로 ...
}

@ConditionalOnProperty 한 줄 어노테이션으로 voice 빈이 spring.ai.model.audio.transcription = openai 일 때만 등록 되도록 풀어요. ollama 프로파일에선 이 프로퍼티가 없으니 빈 자체가 등록되지 않아 충돌이 사라집니다.

이 결의 장점은 프로파일 swap 시 voice 가 자동으로 켜졌다 꺼졌다 하는 점. 본 강의의 프로바이더 추상화 결과 일관됩니다.

두 프로바이더의 검색 결과 비교 (예시)

학생이 측정한 결과는 본인 환경마다 다르지만, 모양은 비슷하게 나옵니다.

질문 Ollama (nomic-embed-text) top-1 score Gemini (768d) top-1 score
"ARIA 분석적 성격" aria-profile.md 0.72 aria-profile.md 0.78
"첫 만남은 어디서 일어났어?" world-lore.md 0.66 world-lore.md 0.71
"AI 친구의 기억 공유 규칙" world-lore.md 0.63 world-lore.md 0.69

핵심 관찰 — 두 프로바이더 모두 같은 top-1 source 를 돌려줘요. score 자리는 Gemini 가 일관되게 5~7% 높게 나오는데, 이는 두 모델의 임베딩 공간 분포 가 다르기 때문이지 Gemini 가 우월하다 는 신호는 아니에요. score 의 절대값 비교는 같은 프로바이더 안에서만 의미 있어요.

Ollama vs Gemini 두 길의 narration

본 강의의 핵심 원칙 — 프로바이더 추상화 — 가 임베딩 영역에서도 그대로 동작한다는 사실을 손에 잡았어요.

자바 코드 한 줄도 안 바뀌고 (EmbeddingServiceEmbeddingModel embeddingModel 필드 그대로) 프로퍼티 한 줄 (spring.ai.model.embedding=ollama=openai) 만 바뀌었습니다.

두 모델은 완전히 다른 데이터셋으로 학습됐고 (Ollama 는 로컬 무료 + 영문 중심·Gemini 는 클라우드 무료 티어 + 다국어 균형), 같은 KB 의 같은 청크가 두 프로바이더에서 완전히 다른 768 차원 벡터 로 박힙니다.

그런데도 검색 결과의 top-1 source 가 같다는 자리 — 이게 임베딩이 의미 공간을 표현 한다는 본질의 증거예요. 모델 모양은 달라도 의미가 가까운 청크는 결국 가까운 자리에 모인다 는 모양.

운영 시점의 결정은 비용 / 지연 / 데이터 주권 세 축의 트레이드오프예요.

  • Ollama — 로컬 호스트 + 무료. KB 전체를 외부 API 로 보내지 않으니 데이터 주권 이 강합니다. 단 호스트 자원 (CPU / GPU) 을 소비하고, 동시 호출 시 응답 속도가 흔들립니다.
  • Gemini — 클라우드 무료 티어 (현 시점 기준 일 1,500 req). 빠르고 다국어 균형이 잡혀 있지만 KB 가 외부로 흘러갑니다. PII 가 박힌 KB 엔 그대로 못 보내요.

흔한 실수

  • 두 프로바이더 사이에 KB 재적재를 안 함nomic-embed-textgemini-embedding-001완전히 다른 모델 이라 벡터 공간이 호환되지 않아요. Ollama 로 적재한 벡터를 Gemini 로 검색하면 nonsense 감이 돌아옵니다. 두 프로바이더 사이엔 반드시 DELETE + POST /ingest 를 다시 돌려야 해요.
  • score 자리만 보고 우열 결정 — score 의 절대값은 같은 프로바이더 안에서만 비교 의미가 있어요. Gemini 의 0.78 이 Ollama 의 0.72 보다 우월 한 게 아니라, 그저 모델의 의미 공간 분포가 다를 뿐.
  • Ollama 가 호스트에서 안 도는데 우회 안 함 — 본 강의는 Ollama 가 호스트에서 도는 그림 이 전제예요. WSL2 / 가벼운 노트북에서 호스트 Ollama 가 안 돌면, Gemini 만 켠 채로 보고서를 마무리 해도 OK. 단 "왜 본 강의가 인터페이스로만 받는가" 의 결을 한 단락 박는 게 채점 포인트.
  • voice 충돌 에러를 무시하고 진행ollama 프로파일로 swap 후 부팅 에러가 뜨면 (A) 또는 (B) 모양으로 해결하고 갑니다. 에러 로그를 무시한 채 Ollama 가 안 도는 으로 결론 내면 본 과제의 본질을 못 잡아요.

실무 개선 포인트

  • 임베딩 모델 버전 메타데이터 박기 — 벡터 저장소에 청크를 박을 때 어느 모델로 임베딩된 청크인지 를 메타데이터에 함께 박아두면, 모델을 갈 때 어느 청크를 다시 임베딩해야 하는지 가 청크 단위로 잡혀요. 예: metadata.put("embeddingModel", "nomic-embed-text-v1.5"); Spring AI 의 PgVectorStore 는 자동으로 박아주진 않지만 ingest 전에 박을 수 있는 자리.
  • 두 프로바이더의 나란히 실행 (shadow embedding) — 운영에선 기본 프로바이더로 임베딩 + 백그라운드로 다른 프로바이더 임베딩 을 동시 적재해 두고, 검색 품질을 정기적으로 비교하는 길이 자주 쓰입니다. 모델을 갈아끼울 시점을 수치로 결정하는 자리.

면접관을 홀리는 핵심 멘트

"EmbeddingModel 인터페이스 한 줄로 Ollama 와 Gemini 두 프로바이더를 자바 코드 한 줄도 안 건드리고 swap 했습니다. 두 모델은 768 차원이라는 골격만 같고, 의미 공간의 분포는 완전히 달라요. 그런데도 같은 질문의 top-1 source 가 두 프로바이더에서 같게 돌아온다는 결과 — 이게 임베딩이 모델 의존이 아니라 의미 의존 이라는 본질의 증거입니다. 실무에선 비용 / 지연 / 데이터 주권 세 축의 트레이드오프로 프로바이더를 고르고, 저는 PII 가 박힌 KB 엔 로컬 Ollama·일반 도메인 KB 엔 클라우드 Gemini 의 길로 가지 않을까 합니다."


생각해볼 주제 예시답안

주제 1 예시답안 — 학습용 768 차원 vs 운영 1536+ 차원

문제 상황 요약

본 강의에서 우리는 Ollama (nomic-embed-text) 와 Gemini (gemini-embedding-001) 양쪽 호환을 위해 768 차원 을 골랐어요.

그런데 진짜 운영 RAG 는 1536 차원 (OpenAI text-embedding-3-small) 또는 3072 차원 (OpenAI text-embedding-3-large / Gemini gemini-embedding-001 풀 차원) 을 자주 씁니다.

차원이 자랄 때 비용 / 속도 / 정확도가 어떻게 변하는지 — 그리고 Gemini 의 MRL (Matryoshka Representation Learning) 절단의 가치가 어디서 빛나는지 모양을 잡는 자리예요.

튜터의 가이드 및 해설

1. 차원이 자랄 때 변하는 네 축

768 차원 1536 차원 3072 차원 한 줄 정리
API 비용 $0.00002 / 1K tok $0.00002 / 1K tok $0.00013 / 1K tok OpenAI 기준 large 가 small 대비 6.5x. Gemini 는 차원이 같은 모델 안에선 비용 동일.
저장소 메모리 청크당 3 KB 청크당 6 KB 청크당 12 KB float32 기준. 100 만 청크면 3 GB / 6 GB / 12 GB.
검색 지연 (ANN) < 5ms ~ 10ms ~ 25ms HNSW 인덱스 기준. 차원이 커질수록 거리 계산 비용 ↑.
recall@10 정확도 90~93% 94~96% 96~98% 도메인마다 다르지만 일반적으로 차원이 클수록 정확도 ↑. 그러나 한계 효용 체감.

768 → 1536 으로 차원을 2 배 키울 때 recall 은 3~6% 만 오르고, 1536 → 3072 으로 또 2 배 키울 때 2~4% 만 오릅니다. 그런데 저장소 메모리와 검색 지연은 비례해서 자라요. 한계 효용 체감이 분명한 자리.

2. Gemini MRL 의 가치

gemini-embedding-001Matryoshka Representation Learning 으로 학습된 모델이에요. 의미가 가장 강한 좌표 가 벡터 앞쪽에 모이도록 학습돼서, 3072 차원으로 적재해 두고 검색 시점에만 768 차원으로 잘라서 쓰는 모양 이 가능합니다.

이 결의 빛나는 자리는 두 가지예요.

  • 검색 시점 비용 최적화 — 적재 시점엔 3072 차원의 풀 정확도를 저장소에 박아두고, 검색 시점엔 768 차원으로 잘라 5 배 빠른 ANN 거리 계산 + 4 배 적은 메모리 캐시 로 응답. 운영에서 비용은 풀, 지연은 작은 차원 자리.
  • 점진적 정확도 향상 (re-ranking) — 1 차로 768 차원에서 top-50 을 빠르게 뽑은 뒤, 2 차로 그 50 개에 대해서만 3072 차원으로 정밀 재정렬. cascading retrieval 이 자주 쓰입니다.

OpenAI 의 text-embedding-3-small/large 도 비슷한 구조로 dimensions 파라미터 로 차원을 줄여 받을 수 있어요. MRL 이 2024 년 이후 업계 표준이 된 자리.

3. Option 비교

  • Option A — 768 차원만 사용 — 본 강의의 학습 설정. Ollama / Gemini 양쪽 호환 + 저장소 메모리 절약. 단 운영의 KB 크기가 수십만 청크로 자라면 recall 한계가 보이기 시작.
  • Option B — 1536 차원 (small 풀) — 학습 / 운영 사이의 안전한 디폴트. OpenAI text-embedding-3-small 의 기본. 비용은 0.00002 / 1K tok 으로 낮고, 본 강의 768 대비 4~5% 정확도 향상.
  • Option C — 3072 적재 + 768 검색 (MRL) — Gemini / OpenAI large 의 구성. 비용은 큰 차원이지만, 검색 지연 + 메모리는 작은 차원. cascading retrieval 의 감을 박은 운영에서 최고.

4. 현업에선 보통

본 강의의 학습 시점768 차원 이 정답이에요. 두 프로바이더의 호환 + 저장소 자원 절약 + 학습 메시지 명료성.

운영의 기본 설정1536 차원 또는 MRL 절단된 3072 가 자주 쓰여요. 단순한 도메인 KB 는 1536 으로 충분하고, 복잡한 다국어 KB / 미묘한 의미 거리를 잡아야 하는 자리엔 MRL 감이 빛납니다.

차원을 키우기 전에 먼저 recall@10 곡선 을 측정해 보는 절차가 정석이에요. 도메인마다 한계 효용 자리가 달라서, 어떤 KB 는 768 에서 이미 96% 가 잡히는 사례도 있어요. 측정 없이 큰 차원으로 가지 않는 자리 이 운영의 핵심.

면접관을 홀리는 핵심 멘트

"임베딩 차원은 클수록 좋다 가 정답이 아닙니다. 768 → 1536 으로 키우면 recall 이 3~6% 오르지만 저장소 메모리는 2 배, 검색 지연도 2 배. 한계 효용이 분명히 체감되는 자리예요. 2024 년 이후의 표준은 Matryoshka Representation Learning — 3072 차원으로 적재해 두고 검색 시점에만 768 차원으로 잘라 쓰는 cascading retrieval 입니다. 비용은 풀 차원의 정확도, 지연과 메모리는 작은 차원의 효율을 둘 한꺼번에 잡는 자리. 실무에선 차원을 키우기 전에 먼저 도메인 KB 의 recall@k 곡선을 측정해 한계 효용 자리를 찾고, 그 자리 너머에서만 큰 차원으로 가는 길이 정석입니다."


주제 2 예시답안 — RAG vs Fine-tuning

문제 상황 요약

LLM 에 도메인 지식을 주입하는 길은 크게 둘이에요. RAG (외부 저장소 + 검색 + 프롬프트 끼움) 와 Fine-tuning (모델 가중치 자체에 추가 학습).

두 가지는 완전히 다른 트레이드오프를 가지고, 실무에선 시나리오마다 골라 씁니다. ARIA 의 일기 회상은 어느 길인지, 캐릭터 톤 박기는 어느 쪽인지 — 자신만의 결정 트리를 그려 보는 자리.

튜터의 가이드 및 해설

1. 두 길의 트레이드오프 표 (2026 년 실무 기준)

RAG Fine-tuning
지식의 휘발성 강함 — KB 갱신만으로 즉시 반영 약함 — 가중치 재학습 필요
지식의 양 수 GB 까지 무난 (벡터 저장소가 받아냄) ~ 수십 MB 정도가 효율적 자리
추적 가능성 강함 — 답변에 출처 청크 박을 수 있음 약함 — 가중치 안에 녹아 출처 추적 어려움
응답 지연 + 검색 지연 (5~50ms) 추가 지연 없음
비용 (운영) 임베딩 API + 벡터 저장소 추론 비용만 (학습은 일회성)
비용 (구축) 낮음 — KB 박고 임베딩만 높음 — 학습 데이터 정제 + GPU 학습 비용
톤 / 스타일 박기 약함 — 프롬프트 컨텍스트로 흉내 강함 — 가중치 안에 새겨짐
환각 (hallucination) 방어 강함 — 명시적 출처 청크에 기반 약함 — 가중치 안에 녹은 지식이 다른 결로 합쳐질 위험

2. 시나리오별 선택

Option A — ARIA 가 마스터의 일기를 회상하는 시나리오 → RAG

마스터의 일기는 매일 자라는 속성 + 각 일기마다 출처 / 날짜가 명확해야 하는 요구 + 환각 시 마스터의 신뢰가 깨지는 위험 셋이라 RAG 가 압도적으로 유리해요.

  • 휘발성 — 매일 새 일기가 들어오는 자리. fine-tuning 으로는 매일 재학습이 불가능.
  • 추적성2026-05-12 일기에서 본 한 줄 의 출처가 답변에 박혀야 마스터가 신뢰. RAG 의 출처 메타데이터가 정확.
  • 환각 방어어제 마스터가 했다는 약속 을 만들어내면 게임이 깨짐. RAG 의 명시적 청크 기반 답변이 안전.
Option B — 세계관 톤을 캐릭터에 박는 자리 → Fine-tuning (또는 시스템 프롬프트 + 소규모 fine-tuning)

ARIA 의 차분한 분석가 톤, HARU 의 활발한 즉흥성 — 이런 말투 / 어조 / 사고 결fine-tuning 이 자연스러워요. 톤은 지식 이 아니라 이라 가중치 안에 새기는 쪽이 강합니다.

  • — 톤 박기엔 수백~수천 예시면 충분. fine-tuning 의 효율적 자리.
  • 휘발성 — 세계관 톤은 한 번 정해지면 거의 안 바뀜. fine-tuning 의 디폴트 가정과 맞음.
  • 응답 지연 — 톤은 답변 시 매번 적용되어야 하니 RAG 처럼 검색하는 모양이 비효율. 가중치 안에 박혀 있어야 자연스러움.

단 fine-tuning 의 비용 / 인프라가 부담이라면, Few-shot 프롬프트 + 시스템 프롬프트의 톤 예시 모양으로 80% 까지는 잡힙니다. 본 강의의 Day 3 PromptTemplate 감이 그 자리.

Option C — 둘 다 쓰는 조합 (RAG + 가벼운 fine-tuning)

운영에선 자주 만나요. 톤 / 어조는 fine-tuning 으로 박고, 매일 자라는 일기 / 사건 은 RAG 로 가져오는 길.

  • ARIA 의 차분한 분석가 톤 은 fine-tuning + 시스템 프롬프트로 박힘
  • 어제 마스터가 한 약속 은 RAG 로 일기 청크 회수
  • 답변 시점에 두 줄기가 한 ChatClient 안에서 합쳐짐

이 hybrid 의 강점은 톤은 일관, 지식은 최신 의 두 축을 한꺼번에 잡는 자리.

3. 현업에선 보통

본 강의의 학습 시점RAG 가 디폴트예요. fine-tuning 은 비용 / 인프라가 무거워 학습 시점엔 부담. 운영의 대부분의 케이스 도 RAG 가 우선이에요 — 지식이 자주 자라거나 정정될 자리라 그렇습니다.

fine-tuning 이 빛나는 자리는 톤·스타일·말투 + 고정된 도메인 어휘 학습 + 작은 모델로 큰 모델 흉내내기 (knowledge distillation) 셋.

본 강의의 미연시 게임으로 보면, 시즌 1 의 캐릭터 톤 이 안정되면 그 시점에 가벼운 fine-tuning 으로 톤을 굳히고, 매일 자라는 일기 / 사건 / 마스터 발화는 RAG 로 흘리는 길이 자연스럽습니다.

선택의 결정 트리는 단순해요. 지식이 자주 바뀌는가? → RAG. 톤 / 스타일을 굳히고 싶은가? → Fine-tuning. 둘 다인가? → 둘 다 쓰는 자리.

면접관을 홀리는 핵심 멘트

"RAG 와 Fine-tuning 의 결정 기준은 지식의 휘발성과 추적 가능성 입니다. 매일 자라는 일기 / 사건 / 사용자 데이터는 RAG — 가중치 재학습 없이 KB 만 업데이트하면 즉시 반영되고, 답변에 명시적 출처가 박혀 환각 방어가 강합니다. 톤 / 말투 / 스타일은 Fine-tuning — 가중치 안에 새겨져야 자연스럽고, 응답 시 추가 지연이 없어요. 운영에선 톤은 fine-tuning, 지식은 RAG 두 축을 같은 ChatClient 안에서 합치는 hybrid 가 자주 쓰입니다. 비용 측면에선 RAG 가 구축 부담이 훨씬 작아 디폴트로 RAG 부터 시작하고, 톤 안정이 절실해진 시점에만 가벼운 fine-tuning 을 추가하는 길이 실용적 정석입니다."


주제 3 예시답안 — KB 의 PII / 임베딩 inversion 공격

문제 상황 요약

마스터의 일기를 임베딩해 pgvector 에 저장한다고 상상해 봐요. 일기 한 줄이 768 차원 실수 배열로 박혀요. "그 배열만으로 원문을 복원할 수 있나?" 라는 의문이 자연스럽게 떠오릅니다.

2026 년 기준 결론은 부분적 복원 가능 — 완전한 원문은 어렵지만 민감 정보 (이름·전화번호·날짜) 의 단편은 통계적으로 복원 가능한 사례가 있어요. PII 가 박힌 KB 를 운영에 박을 때 어떤 안전선이 필요한지 짚는 자리예요.

튜터의 가이드 및 해설

1. Embedding Inversion 공격의 2026 년 현황

임베딩이 비가역적이라고 알려져 있던 통념 은 2023 년 이후 깨졌어요. Vec2Text (Morris et al., 2023) 류 모델이 임베딩 → 원문 복원에 부분 성공했고, 그 뒤로 부분적 복원 가능 이 학계 정설이 됐어요.

복원이 빛나는 자리는 다음 셋이에요.

  • 공격자가 같은 임베딩 모델을 알고 있을 때nomic-embed-text 의 벡터라면 같은 모델로 역공학 가능.
  • 공격자가 학습 데이터의 분포를 알 때 — 일기 / 의료 기록 / 채팅 같은 도메인이 알려진 자리.
  • 민감 정보가 정형 형식일 때 — 전화번호 (010-XXXX-XXXX) / 주민번호 / 이메일 형식은 부분 복원 정확도가 70%+.

반면 자연스러운 평문의 완전한 원문 복원 은 여전히 어려워요. 보통 키 단어 / 핵심 명사 / 정형 ID 만 복원되는 결이지만, 이름과 한 날짜만 복원되어도 PII 누출 이라 가볍게 볼 수 없습니다.

2. 네 가지 안전선

Option A — 임베딩 저장 시 별도 암호화

벡터 자체를 DB 컬럼 레벨에서 암호화 (예: pgcrypto / AWS RDS encryption-at-rest) 하는 단. 저장소 침입 시점의 1 차 방어.

  • 장점 — 침입자가 DB 덤프를 떠도 키 없이는 벡터를 읽지 못함.
  • 단점 — 검색 시점에 복호화 가 필요한데, 벡터 검색은 암호화된 채로 거리 계산이 불가능. Homomorphic Encryption 모양이 있지만 2026 년 현재 비용 / 지연이 비현실적.
  • 현실at-rest 암호화 + 접근 권한 분리 의 조합으로 가는 게 일반적.
Option B — 메타데이터에서 PII 마스킹

벡터 안엔 본문 의미만 남기고, 직접 식별 정보 는 메타데이터 / 원문 텍스트에서 마스킹하는 단. Microsoft Presidio / AWS Comprehend Medical 류 PII 탐지 + 마스킹 도구가 표준.

  • 장점 — 적재 시점에 민감 정보가 임베딩에 박히기 전 에 마스크. 임베딩 inversion 으로 복원되는 결과도 마스크된 토큰 만 복원.
  • 단점 — 마스크 후엔 답변에서 "010-XXXX-1234 에 전화하세요" 같은 감이 "전화번호가 마스크되어 있어요" 로 바뀌어 답변 품질 / 자연스러움 저하.
  • 현실공개 KB / 공유 KB 모양엔 강력 마스킹, 개인 KB 자리엔 약한 마스킹 + 다른 안전선과 조합.
Option C — 임베딩 접근 권한 분리

KB 를 마스터 ID 단위로 격리 하는 결. 마스터 1 의 일기 청크는 마스터 1 의 세션만 검색 가능 + 마스터 2 의 청크는 보이지 않는 자리.

  • 장점수평 침입 (다른 마스터의 KB 가 한 마스터에게 흘러가는 결) 방어. 운영 안전선의 디폴트.
  • 단점 — pgvector 의 WHERE 절 + 메타데이터 필터링이 필수. 본 강의의 Step 6 에서 잠깐 짚은 자리예요.
  • 현실SimilaritySearchRequestfilterExpression 으로 master_id == ${currentMasterId} 한 줄을 박는 자리. 운영의 첫 번째 필수 안전선.
Option D — 벡터 컬럼 자체의 접근 로그

누가 / 언제 / 어느 쿼리로 KB 를 검색했는지 모두 로그 로 박는 길. 사후 추적의 디폴트.

  • 장점 — 침입이 일어났을 때 누가 어디까지 봤는지 추적 가능. 컴플라이언스 (GDPR / 개인정보보호법) 모양.
  • 단점 — 로그 자체가 PII 의 모양이라 로그의 안전선 이 또 필요. 양도 큼.
  • 현실민감 KB 자리 엔 거의 필수. 일반 KB 엔 sampling 으로 가도 OK.

3. 학습용 vs 운영용의 자리 차이

자리 학습용 (본 강의) 운영용
KB 데이터 캐릭터 프로필 / 세계관 (가공된 텍스트) 마스터의 일기 / 채팅 / 일정 (실제 PII)
안전선 거의 없음 — 학습이 목표 A·B·C·D 네 단 조합 필수
가장 시급한 자리 (없음) C — 권한 분리 가 디폴트 첫 단계
두 번째 자리 (없음) B — PII 마스킹 적재 전

4. 운영에선 가장 시급한 자리

본 강의 코드로 운영에 들어간다면 C (권한 분리) 가 첫 번째 단계예요. 수평 침입 (다른 마스터의 KB 가 흘러가는 결) 이 가장 흔한 침입 벡터고, 권한 분리 한 줄 코드 (filterExpression) 가 가장 적은 비용으로 가장 큰 그림을 잡는 자리.

그 다음 B (PII 마스킹) — 적재 전 마스크가 inversion 공격 자체를 무력화하는 단계. Microsoft Presidio 류를 ingest 파이프라인에 한 단 박는 길이 일반적.

A 와 D 는 대규모 침입 시점의 2 차·3 차 방어선 이라, 디폴트 운영의 첫 자리는 아니지만 민감 KB / 컴플라이언스 자리엔 필수.

면접관을 홀리는 핵심 멘트

"임베딩이 비가역적이라는 통념은 2023 년 Vec2Text 류 연구로 깨졌습니다. 완전한 원문 복원은 어렵지만 전화번호 / 이름 / 날짜 같은 정형 PII 의 부분 복원은 통계적으로 가능합니다. 따라서 PII KB 의 안전선은 권한 분리 → PII 마스킹 → 저장소 암호화 → 접근 로그 네 단의 다층 방어가 정석. 가장 첫 단계는 권한 분리 — pgvector 의 filterExpression 한 줄로 마스터 ID 단위 격리가 가장 적은 비용으로 가장 큰 감을 잡습니다. 임베딩 모델이 알려져 있고 도메인이 알려져 있다면 inversion 공격이 현실인 상황이라, 공개 / 공유 KB개인 PII KB 는 결을 완전히 다르게 잡아야 합니다."


마치며 — 두 트랙을 짚으며

오늘 답안의 두 트랙을 짧게 정리할게요.

과제 트랙 — 세 과제가 난이도 사다리 로 짜여 있었어요.

  • 🌱 NOA 추가 — 코드 한 줄 안 건드리고 KB 만 자라는 자리.
  • 🪪 청크 크기 비교 — 트레이드오프가 내 KB 특성 + 질문 결의 조합 에 따라 달라진다는 점.
  • 🦙 프로바이더 swap — 인터페이스 한 줄로 Ollama ↔ Gemini 가 0 줄 swap 되는 결.

생각해볼 주제 트랙 — 세 주제 모두 2026 년 RAG 운영 의 핵심 트레이드오프였어요.

  • 768 vs 1536 vs 3072 — 차원이 클수록 좋은 게 아니라, 한계 효용 / cascading retrieval 의 그림.
  • RAG vs Fine-tuning지식의 휘발성 / 톤·스타일 의 두 축으로 결정.
  • PII inversion권한 분리 → 마스킹 → 암호화 → 로그 네 단의 다층 방어.

다음 시간엔 오늘 손에 박은 검색 결과 가 ChatClient 의 답변에 자동으로 끼워지는 advisor — RetrievalAugmentationAdvisor 를 만나요. 오늘은 검색까지가 손목 단계였다면, 다음 시간엔 RAG 의 G 자리가 한 묶음으로 완성되는 자리예요.

더 배우려면

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

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