Day 16. RAG 심화 — "5 부품 위에 advisor 하나로 자동 RAG 가 도는 첫 시간"
안녕하세요, 여러분의 Spring AI 가이드 홍순구 튜터입니다.
지난 시간 우리는 ARIA 의 한 줄에서 멈칫했어요.
마스터: "ARIA, 어제 우리가 마지막으로 나눈 약속이 뭐였더라?" ARIA: "음... 제 기억엔 한정된 컨텍스트만 있어서, 어제의 대화 전체를 짚어드리긴 어려워요."
모델 가중치 안에는 마스터가 적어둔 어제 일기가 없고, ChatMemory 가 잡아주는 범위는 지금 이 대화 안까지였죠. 그래서 외부 지식을 어딘가에 따로 저장해둬야 답이 나오는 흐름이 필요했어요.
그래서 Day 15 에 5 부품을 한 세트로 갖췄어요. 임베딩을 만드는 EmbeddingService, pgvector 위의 PgVectorStore 를 직접 등록한 VectorStoreConfig, KB 파일을 청크로 자르는 DocumentLoaderService 가 한 축이고요. 적재 흐름을 한 줄로 묶은 CharacterKnowledgeIngestionService, REST 4 엔드포인트를 열어둔 CharacterKnowledgeController 까지. 이 다섯 부품이 살아 있는 위에서 오늘 자동화가 시작돼요.
그런데 지난 시간의 흐름을 한 번 더 짚어볼게요. CharacterKnowledgeController 의 /api/rag/knowledge/search 엔드포인트로 우리는 검색을 직접 돌렸어요. curl 한 줄로 질문을 던지고, 청크 N 개를 받고, 그 청크를 우리가 눈으로 읽었죠. 비즈니스 ChatClient 인 SoulmateChatController 는 여전히 외부 지식 없이 답하는 그림 그대로였어요.
이건 학습용 lab 단계예요. "검색이 정말 의미 기반으로 도는가" 를 직접 확인하려고 일부러 분리해 둔 과정이죠. 오늘은 그 lab 이 prod 안으로 흡수되는 골격을 본격적으로 다뤄요. 검색→프롬프트 끼움이 ChatClient 호출 안에서 자동으로 동작하는 장면으로 넘어가요.
그 과정을 추상화해 주는 도구가 오늘의 주인공이에요. RetrievalAugmentationAdvisor — advisor 라는 이름 뒤에 질문 임베딩 → 청크 검색 → 프롬프트에 끼움까지 자동으로 돌아가요. Day 14 에 4 가드 advisor 를 외→내 순서로 깔았던 장면 기억나시죠? RAG 도 그 자매 추상화 위에서 동작해요. ChatClient 한 줄 옆에 advisor 한 줄이 더 붙을 뿐인데, ARIA 가 "어제 약속이 뭐였더라" 에 진짜로 답하기 시작해요.
💡 오늘 수업의 핵심
"5 부품 위에 advisor 하나가 자동으로 결합되어, ARIA 가 세계관 범위 안에서만 답하는 구조까지 이해하는 첫 시간"
세 줄로 풀어 둘게요.
- advisor 하나로 흡수 — Day 15 의 손 검색이 ChatClient 한 번 안의 자동 검색→프롬프트 끼움으로 자라요.
RetrievalAugmentationAdvisor가 그 끼움점이에요. - 운영 부품 5 개 묶음 위에서 자란다 — 청크 전략 트레이드오프 · 형식 만능 흡수 (Tika) · 캐릭터별 KB 분리 (metadata 필터) · 검색 품질 평가 (recall@k / precision@k) 까지 한 번에.
- 다음 시간 MCP 로 잇는 다리 — 오늘 등록한 advisor 끼움점이 다음 시간 MCP 의 외부 도구 호출로 자라요. 모듈러 RAG 의 역할.
🙋 한 학생의 걱정
"튜터님, 어제 5 부품을 만들었는데 또 다른 advisor 를 하나 더 추가하는 건가요? 부품이 자꾸 늘어나는 느낌인데..."
좋은 질문이에요. 결론부터 말하면 부품이 늘어나는 게 아니라 기존 부품이 자동으로 결합되는 구조가 생겨요.
advisor 는 그 자체로 새 부품이 아니에요. 끼움점 (extension point) 이라는 추상화예요. Day 14 에 가드 4 advisor 를 외→내 순서로 깔았던 자매 추상화 기억나시죠?
오늘 추가하는 RAG advisor 도 같은 곳에 하나 더 합류하는 방식이에요. 그 advisor 가 Day 15 의 5 부품 (EmbeddingService · VectorStoreConfig · DocumentLoaderService · CharacterKnowledgeIngestionService · CharacterKnowledgeController) 을 자동으로 호출해 줘서, 호출자 (SoulmateChatService) 는 RAG 의 존재 자체를 모른 채 한 줄로 답을 받아요.
부품이 늘어나는 게 아니라 부품이 자동 결합 되는 구조가 생기는 거예요.
🎯 학습 목표
RetrievalAugmentationAdvisor한 세트가 검색→프롬프트 끼움까지 어떻게 자동으로 도는지 이해해요.SoulmateChatController의 비즈니스 ChatClient 가 KB 를 직접 읽지 않고도 advisor 한 줄로 외부 지식을 흘려받는 구조를 갖춰요.- 청크 크기 (200 vs 500 vs 1000) 트레이드오프와 한국어 분할의
punctuationMarks옵션을 본격적으로 다뤄요. DocumentReader형식 확장 — Tika 만능 vs PDF 전용의 비교 절차를 익혀요.- metadata 필터 +
Supplier<FilterExpression>로 ARIA 전용 KB 와 HARU 전용 KB 가 같은 pgvector 안에서 분기되는 구조를 만들어요. - 검색 품질 평가 (recall@k / precision@k) 의 첫 그림을 잡아 둬요.
Step 1. RetrievalAugmentationAdvisor 하나로 흡수되는 advisor 아키텍처
도입부에서 우리가 잡아둔 큰 그림 기억나시죠? Day 15 의 5 부품 위에 advisor 하나가 자동 결합되면 ChatClient 안에서 검색→프롬프트 끼움이 알아서 돈다는 이야기였어요. 이번 Step 에서는 그 "한 장" 이 실제로 어떻게 생겼고, 비즈니스 ChatClient 옆에 어떻게 흘러 들어가는지 확실히 이해할 수 있게 들여다봐요.
오프닝에서 우리가 잡아둔 큰 그림 기억나시죠? Day 15 의 5 부품 위에 advisor 하나가 자동 결합되면 ChatClient 안에서 검색→프롬프트 끼움이 알아서 돈다는 이야기였어요. 이번 Step 에서는 그 "한 장" 이 실제로 어떻게 생겼고, 비즈니스 ChatClient 옆에 어떻게 흘러 들어가는지 확실히 이해할 수 있게 들여다봐요.
흐름은 단순해요. Spring AI 가 제공하는 RAG advisor 가 두 길로 있는데, 우리는 그 중 하나를 prod 에 등록해요. 그 advisor 를 빈으로 정의하고, ChatClient 의 defaultAdvisors 에 합류시키는 룰이에요. 이 두 손짓이 끝나면 SoulmateChatService.chat() 한 줄이 호출될 때 외부 지식이 자동으로 끼워져요.
두 advisor 비교 — Naive RAG vs Modular RAG
Spring AI 1.1 에는 RAG advisor 가 두 종류 들어 있어요. 두 advisor 모두 같은 vectorStore 를 받아서 유사도 검색을 돌리는데, 제공하는 확장점의 갯수가 달라요.
| 축 | QuestionAnswerAdvisor (Naive RAG) |
RetrievalAugmentationAdvisor (Modular RAG) |
|---|---|---|
| 한 줄 정의 | 검색 → 프롬프트 끼움. 끝. | retrieve + augment + (선택) transform + (선택) join 등 모듈 결합 |
| 확장 포인트 | vectorStore · searchRequest 정도 |
documentRetriever · queryAugmenter · queryTransformers · documentJoiner · documentPostProcessors |
| 학습 곡선 | 짧음, 첫 RAG 체감용에 좋음 | 약간 길지만 한번 익히면 모듈 갈아끼우기 자유 |
| 향후 확장 | 새 단계 끼우려면 advisor 자체를 바꿔야 함 | 같은 advisor 안에서 모듈 한 줄 교체 |
| Day 17 이후 결합 | 어색함 (MCP 도구도 같은 advisor 슬롯에 끼우려면 분기) | 자연스러움 (queryTransformers 자리에 MCP 도구 합류 가능) |
우리는 후자를 prod 에 둬요. 이유는 한 줄 — 다음 시간 MCP 가 같은 advisor 라는 끼움점으로 들어오는데, advisor 가 모듈 합성 구조여야 그 확장이 자연스럽게 열려요. 게다가 Spring AI 공식 문서도 신규 프로젝트는 Modular RAG 쪽을 권장하는 흐름이에요.
🙋 학생 질문 — "튜터님, 두 advisor 가 같은 검색을 한다면 왜 굳이 둘이 공존하나요? 하나만 두면 안 되나요?"
좋은 질문이에요. 두 advisor 는 같은 목적지를 가리키지만 서로 다른 학습 곡선을 의도하고 있어요.
QuestionAnswerAdvisor 는 "RAG 가 뭔지 한 줄로 보여주는" 입문 도구예요. 코드 두 줄이면 첫 RAG 가 도는 그림을 이해할 수 있어서 학습 진입에 좋아요.
RetrievalAugmentationAdvisor 는 "RAG 파이프라인을 모듈로 조립하는" 본격 도구예요. retriever 모듈, augmenter 모듈, transformer 모듈이 분리되어 있어서, 검색 전 질문을 다시 쓰거나(query rewriting), 검색 결과를 후처리하거나, 두 vectorStore 의 결과를 합치는(joiner) 같은 모듈을 한 줄씩 갈아끼울 수 있어요.
Spring AI 가 두 advisor 를 공존시키는 건 의도예요. 학습 진입은 Naive 로, prod 확장은 Modular 로 — 두 호흡을 다 잡아주는 거죠. 우리 강의는 Day 15 에서 lab 으로 검색 자체의 동작을 익혔으니, Day 16 부터는 본격 모듈러 쪽으로 자라요.
spring-ai-rag 의존성 한 줄 — RAG 모듈은 별도 artifact
Day 15 까지는 spring-ai-starter-vector-store-pgvector 하나가 임베딩 모델 자동 구성 + pgvector 빈까지 끌고 와줬어요. 그런데 RAG advisor 와 retriever/augmenter 같은 파이프라인 모듈은 별도 artifact 에 들어 있어요. Spring AI 1.1.0 부터의 정리예요 — 검색 자체와 RAG 파이프라인을 분리해서 의존성 표면을 깔끔하게 가져가는 과정이죠.
// lecture-source-code/ai-friends/build.gradle (L64)
implementation 'org.springframework.ai:spring-ai-rag'
이 한 줄로 RetrievalAugmentationAdvisor · VectorStoreDocumentRetriever · ContextualQueryAugmenter 세 클래스가 한 번에 따라와요. 빌드 도구가 ./gradlew bootRun 직전에 의존성을 한 번 받아주면 다음 단계로 넘어갈 수 있어요.
RagAdvisorConfig — advisor 하나의 실체
이제 advisor 빈을 등록할 차례예요. kr.spartaclub.aifriends.rag.config.RagAdvisorConfig 클래스 에 RAG advisor 의 형태이 다 들어가요. 핵심 빈 메서드를 펼쳐 볼게요.
// kr.spartaclub.aifriends.rag.config.RagAdvisorConfig
// 전체 코드: lecture-source-code/ai-friends/src/main/java/.../rag/config/RagAdvisorConfig.java
@Configuration
@ConditionalOnProperty(name = "ai-friends.rag.enabled", havingValue = "true", matchIfMissing = true)
public class RagAdvisorConfig {
/** 유사도 임계값. 너무 멀어진 문서는 컨텍스트에서 제외. */
public static final double DEFAULT_SIMILARITY_THRESHOLD = 0.50;
/** 한 번 검색에 끼울 최대 청크 수. Step 2 에서 직접 조정해 본다. */
public static final int DEFAULT_TOP_K = 5;
@Bean
public RetrievalAugmentationAdvisor characterKbAdvisor(VectorStore vectorStore) {
return RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(DEFAULT_SIMILARITY_THRESHOLD)
.topK(DEFAULT_TOP_K)
.build())
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(false)
.build())
.order(0)
.build();
}
}
하나씩 풀어볼게요.
@ConditionalOnProperty(... matchIfMissing = true) — 클래스 전체가 ai-friends.rag.enabled 프로퍼티로 켜고 끌 수 있어요. 기본값은 true 라 프로퍼티를 안 설정해두면 RAG advisor 가 켜진 장면이에요.
토글 한 줄로 ARIA 가 KB 를 보는 장면과 안 보는 상태를 갈아끼울 수 있어요. RAG on/off 비교 시연에 자연스럽게 활용돼요.
VectorStoreDocumentRetriever — Day 15 에 등록한 vectorStore 빈을 그대로 받아 1차 검색을 도는 모듈이에요. similarityThreshold(0.50) 은 "유사도 50% 미만 청크는 컨텍스트에서 제외" 라는 차단선이에요.
topK(5) 는 "한 번에 최대 5 청크까지만 끼움" 이라는 상한선이고요. 이 두 값이 retriever 의 한 그림을 정해요 — 너무 멀어진 청크가 끼면 LLM 이 헷갈리고, 너무 많이 끼면 토큰 비용이 부풀어요.
ContextualQueryAugmenter(allowEmptyContext=false) — 검색 결과를 user 프롬프트의 컨텍스트 위치에 끼워 주는 모듈이에요. allowEmptyContext=false 가 핵심이에요.
검색이 0건이면 LLM 호출 자체를 fallback 응답으로 단락시켜요. "RAG 가 의미를 가지려면 컨텍스트가 진짜로 끼워져야 한다" 는 길을 advisor 차원에서 강제하는 구조예요. 검색이 비었는데도 LLM 이 그냥 답해버리면 RAG 의 본질이 무너지거든요.
order(0) — advisor 가 ChatClient 안에서 도는 순서예요. Day 5 에 등록한 MessageChatMemoryAdvisor 가 기본 순서로 도는데, RAG advisor 를 그보다 앞에 (order(0)) 두는 이유는 단순해요. RAG 컨텍스트가 먼저 끼워진 뒤에 이전 대화가 합쳐져야, LLM 이 "세계관 + 어제 대화" 두 줄을 한 번에 받게 돼요.
ChatClientConfig.soulmateChatClient — advisor 흡수 지점
advisor 빈은 등록됐지만 아직 ChatClient 가 모르는 상태예요. soulmateChatClient 빈에 합류시켜야 prod 흐름이 advisor 를 보게 돼요. Day 5 에 등록했던 한 줄짜리 defaultAdvisors(MessageChatMemoryAdvisor) 를 List 로 확장해요.
// kr.spartaclub.aifriends.chat.config.ChatClientConfig
// 전체 코드: lecture-source-code/ai-friends/src/main/java/.../chat/config/ChatClientConfig.java
@Bean
public ChatClient soulmateChatClient(ChatClient.Builder builder,
ChatMemory chatMemory,
ObjectProvider<RetrievalAugmentationAdvisor> ragAdvisorProvider) {
List<Advisor> advisors = new ArrayList<>();
advisors.add(MessageChatMemoryAdvisor.builder(chatMemory).build());
ragAdvisorProvider.orderedStream().forEach(advisors::add);
return builder
.defaultAdvisors(advisors)
.build();
}
이번에도 하나씩 풀어볼게요.
ObjectProvider<RetrievalAugmentationAdvisor> — Spring 의 옵셔널 주입 형이에요. @Autowired 로 직접 받으면 빈이 없을 때 부팅 자체가 깨지는데, ObjectProvider 로 받으면 빈이 있든 없든 컴파일/부팅이 깨지지 않아요.
위에서 설정한 @ConditionalOnProperty 토글이 off 일 때도 ChatClient 가 그대로 살아 있도록 보장하는 장치예요. 토글 off 상태면 RAG advisor 빈이 0 개라 List 에 끼워지지 않고, ChatMemory advisor 한 줄만 남아 Day 15 까지의 상태와 동등한 형태이 돼요.
orderedStream() — ObjectProvider 의 메서드인데, 빈 여러 개가 있을 때 advisor 의 @Order 또는 getOrder() 를 존중해서 정렬해 줘요. 우리가 advisor 에 order(0) 을 설정한 이유가 여기서 반영돼요. 미래에 advisor 가 또 한 장 추가돼도 (예: 다음 시간 MCP advisor) order 값만 적절히 설정하면 자동으로 정렬돼요.
List<Advisor> defaultAdvisors — Day 5 의 advisor 한 줄이 Day 16 에서 List 로 확장되는 구조예요. ChatClient 한 번 안에 advisor 가 몇 개든 끼울 수 있는 그릇이 열려요. 여기가 오늘 골격이에요. 여기가 진짜 핵심이에요.
Before/After — Day 15 lab 수동 검색에서 Day 16 prod 자동화로
도입부에서 lab → prod 흡수 흐름을 약속했죠. 이제 그 과정이 코드에서 어떻게 드러나는지 한 표로 파악해 둘게요.
| 축 | Before (Day 15 까지) | After (Day 16 Step 1) |
|---|---|---|
| 검색 호출 지점 | CharacterKnowledgeController.search lab 엔드포인트 |
SoulmateChatService.chat() prod 호출 안 |
| 누가 검색하나 | 학생/강사/디버거 (사람이 curl 한 줄) | advisor (코드, 한 번 안 자동) |
| 프롬프트 끼움 지점 | (없음 — 검색 결과를 사람이 눈으로만) | ContextualQueryAugmenter 가 user 프롬프트에 자동 |
| ChatClient 시그니처 | defaultAdvisors(MessageChatMemoryAdvisor) 한 줄 |
defaultAdvisors(List<Advisor>) + RAG 옵셔널 합류 |
| KB 한도 인지 | 호출자가 매번 검색 범위를 직접 짜야 함 | retriever 안 topK=5 가 보장 |
| 검색 결과 누락 시 | 사람이 눈으로 0건 확인하고 다시 질문 | allowEmptyContext=false 가 fallback 응답으로 단락 |
Day 15 의 lab 은 "검색이 정말 의미 기반으로 도는지" 를 직접 확인하려고 일부러 분리해 둔 거였어요. Day 16 부터는 그 흐름 전체가 ChatClient 한 세트 안으로 흡수돼서, 호출자(서비스)는 RAG 의 존재를 모른 채 한 줄로 답을 받아요. 같은 advisor 한 줄 추가 구조로 다음 시간 MCP 도 들어와요 — 외부 도구를 표준 프로토콜로 받아 오는 흐름이 거기에 합류할 거예요.
💡 튜터의 결론
오늘 추가한 게 advisor 하나가지만, 사실 우리가 만든 건 advisor 라는 끼움점 자체예요. 다음 시간 MCP 도, 그 다음의 도구 호출 advisor 도 같은 곳에 줄을 서서 들어와요. ChatClient 한 줄 옆에 advisor 한 줄이 더 붙는 형태로 도메인 기능이 자라는 과정 — 이것이 Spring AI 가 우리에게 선물하는 모듈러 아키텍처의 본질이에요.
자, 검색→프롬프트 끼움이 자동화되는 구조까지 이해했어요. 그런데 advisor 가 의미 있는 답을 만들려면 받아 먹는 청크가 좋아야겠죠. 다음 Step 에서는 청크 크기 트레이드오프와 한국어 분할 옵션으로 그 재료를 깎는 부분을 본격적으로 다뤄요.
Step 2. 청크 전략 트레이드오프 — 크기·최소 문자 수·한국어 마크
Step 1 에서 advisor 하나가 검색→프롬프트 끼움을 자동화하는 구조를 봤죠. 그런데 advisor 의 답 품질은 한 가지에 크게 매여 있어요. 받아 먹는 청크가 좋아야 한다는 것. retriever 가 아무리 잘 골라줘도, 청크 자체가 의미 단위로 안 끊겨 있으면 모델이 받아 답할 문맥이 흐트러져요.
이번 Step 에선 그 청크를 빚는 부분, DocumentLoaderService.chunk() 안을 본격적으로 들여다봐요. Day 15 에 우리가 한 번 손으로 잡았던 메서드인데, 두 가지 큰 발견이 기다리고 있어요. 하나는 Day 15 의 변수명이 잘못 지정되어 있었다는 것, 또 하나는 Spring AI 의 분할기가 한국어 마침표를 알아보지 못한다는 것이에요.
청크 크기 트레이드오프 — 크다고 좋은 게 아니다
청크 전략은 두 후보의 균형점이에요. 크게 자르면 한 청크 안에 많은 문맥이 담겨서 모델이 한 번 검색에 풍부한 정보를 받아요. 단, 한 청크가 무겁다 보니 질문과 직접 관계없는 부분까지 함께 끌려와요. 검색 정밀도가 떨어지는 거예요.
반대로 작게 자르면 청크 하나하나의 의미가 더 또렷해져요. 검색 단계에서 정말 관련 있는 청크만 골라내기 쉬워져요. 단, 한 청크만으로는 답할 문맥이 부족할 수 있어요. 모델이 5~10 청크를 모아 한 답을 만들어야 하는 흐름이 자주 나타나요.
균형점이 어디냐 — 한국어 평문 기준 한 문단 ~ 한 문단 반 정도가 LLM 입장에서 한 번에 받기 좋은 분량이에요. 토큰 단위로 환산하면 대략 500 토큰. 이게 학습용 기본값이고, 본 강의에선 DEFAULT_CHUNK_TOKENS = 500 으로 넣어뒀어요.
검증된 숫자를 한 표로 보여드릴게요. 본 강의 코드베이스의 3 개 KB 파일 (ARIA 프로필 + 하루 프로필 + 세계관 설정집, 합 5,144 byte) 위에서 청크 크기를 200 / 500 / 1000 으로 바꿔보면 이런 결과가 나와요.
| chunkSize | minChunkSizeChars | 청크 수 | 한 청크 분량 (대략) |
|---|---|---|---|
| 200 | 100 | 13 | 한 문단의 1/2 ~ 한 문단 |
| 500 (default) | 350 | 6 | 한 문단 ~ 한 문단 반 |
| 1000 | 700 | 3 | 거의 파일 1개를 1청크로 |
한눈에 잡히는 길이 있죠. 청크 크기를 올리면 청크 수가 떨어져요. 5,144 byte 짜리 KB 가 13 → 6 → 3 으로 압축돼요. 다만 청크 수가 적다는 게 검색 품질이 낮다는 뜻은 아니에요. 한 청크가 크면 한 번 검색에 많은 문맥이 따라와 모델이 답하기 좋지만, 검색 정밀도는 떨어지고요. 작으면 정밀하지만 답할 문맥이 부족할 수 있어요. 트레이드오프의 문제이지 정답이 하나가 아니에요.
🙋 학생 질문 — "튜터님, 청크가 크면 한 청크 안에 정보가 많아서 좋은 거 아닌가요? 왜 작게도 자르는 옵션이 있죠?"
좋은 질문이에요. 청크가 크면 한 청크의 정보량만 놓고 보면 분명 풍부해요. 하지만 RAG 에서 우리가 보는 건 두 거예요. 검색 단계 와 생성 단계 가 따로 돌아요.
검색 단계에서 우리는 유사도 점수로 청크를 골라요. 청크가 크면 그 청크 안에 질문과 관련 없는 내용까지 함께 들어 있어요. 임베딩 벡터는 그 청크 전체의 평균 의미를 표현하니까, 관련 없는 부분이 평균을 흐려요. 결과적으로 진짜 답이 있는 작은 청크가 큰 청크에 밀리는 현상이 생겨요.
생성 단계에서는 반대로 청크가 너무 작으면 모델이 한 답을 만들 만한 문맥이 부족해요. 5~10 청크를 모아도 끊긴 문장만 와르르 쌓이면 모델이 이게 한 문맥인지 다른 문서의 조각인지 헷갈리죠.
그래서 청크 크기는 내 KB 의 평균 의미 단위와 내 질문 룰의 조합으로 정해져요. 한국어 인물 프로필이면 한 문단 ~ 한 문단 반 (500 토큰) 이 보통 균형점이고, 코드 스니펫이면 함수 단위 (200~300 토큰) 가 잘 맞고, 긴 논문이면 한 섹션 (1000+ 토큰) 이 자연스러워요.
minChunkSizeChars 의 정체 — Day 15 의 변수명 정정
여기서 Day 15 의 한 가지를 짚고 갈게요. Day 15 의 DocumentLoaderService 가 TokenTextSplitter 의 5인자 생성자를 호출하면서, 두 번째 인자를 DEFAULT_CHUNK_OVERLAP = 50 이라는 이름으로 넣어뒀어요. "청크 사이 겹치는 문자 수" 라는 뉘앙스의 이름이었죠.
그런데 공식 문서를 한 번 더 들여다보니, 그 인자의 진짜 이름은 minChunkSizeChars 였어요. 의미도 사뭇 달라요. "청크가 종결 지점을 찾기 위해 갖춰야 할 최소 문자 수" — 청크가 너무 짧은 지점에서 마침표를 만나면 너무 일찍 끊는 것으로 간주해 무시한다는 뜻이에요.
라이브러리 시그니처를 한 번에 다 파악하긴 어려워요. 사용하면서 마주치는 정정 과정이 자연스러운 절차이고, 그래서 Day 16 부터는 이 이름을 정정했어요. 값도 겹침의 뉘앙스로 잡았던 50 보다 훨씬 큰, 한 문장 ~ 두 문장 길이인 350 으로 올렸어요.
// kr.spartaclub.aifriends.rag.service.DocumentLoaderService
public static final int DEFAULT_CHUNK_TOKENS = 500;
/**
* 청크가 종결 자리를 찾기 위해 갖춰야 할 최소 문자 수.
* 이 값보다 짧은 자리에서 만난 마침표는 너무 일찍 끊는 자리로 간주해 무시.
*/
public static final int DEFAULT_MIN_CHUNK_SIZE_CHARS = 350;
public List<Document> chunk(List<Document> rawDocuments) {
return chunk(rawDocuments,
DEFAULT_CHUNK_TOKENS,
DEFAULT_MIN_CHUNK_SIZE_CHARS,
KOREAN_PUNCTUATION_MARKS);
}
두 상수의 형태을 보세요. DEFAULT_CHUNK_TOKENS = 500 은 토큰 단위 청크 크기. 한국어 평문 기준 한 문단 ~ 한 문단 반이에요. DEFAULT_MIN_CHUNK_SIZE_CHARS = 350 은 문자 단위. 한 문장 ~ 두 문장 길이의 안전선이에요. "이 길이 이하에서 만난 마침표는 무시한다" 의 의미예요.
그리고 기본 chunk(List) 메서드는 Day 15 의 호출자가 깨지지 않도록 시그니처를 그대로 유지했어요. 내부에서 본격 오버로드를 부르는 위임 한 줄로 갈아끼웠죠. Day 15 의 CharacterKnowledgeIngestionService 도, EmbeddingProbeController 도 코드 한 줄 안 바뀌고 그대로 돌아가요.
💡 튜터의 결론
라이브러리 시그니처를 한 번에 다 파악하긴 어려워요. 사용하면서 정정하는 게 자연스러운 과정이에요. Day 15 의 변수명이 잘못된 걸 부끄러워하지 말고, 어떻게 발견했는지의 과정을 익혀두는 게 더 큰 값이에요. 공식 문서 다시 읽기 → 시그니처 한 번 더 확인 → 변수명 정정 → 호출자 호환 유지의 과정을 정리해두면, 앞으로 마주칠 비슷한 지점에서도 같은 방식으로 처리하실 수 있어요.
한국어 punctuationMarks 큰 발견 — 영어 4종 하드코딩 우회
이제 두 번째 큰 발견이에요. Spring AI 1.1.x 의 TokenTextSplitter 는 문장 종결 후보 마크를 클래스 안에 영어 4종 (., ?, !, \n) 으로 하드코딩되어 있어요. 즉, 청크를 자를 때 마지막 마침표 지점을 찾아 거기서 깔끔하게 끊는 알고리즘인데, 그 마침표라는 후보가 영어 기준이에요.
한국어 문서에 한자 마침표 패밀리 (。, ?, !) 가 섞이면 분할기가 못 알아봐요. 그러면 어떻게 되냐 — 그 마침표를 무시하고 토큰 한계까지 끌고 가다가, 토큰 한계 안에서 어쩌다 마주친 영문 마침표 지점에서 끊어요. 결과는 문장 한가운데가 잘리는 구조예요. 검색 품질이 망가지죠.
해결책의 첫 후보는 외부 라이브러리 도입이에요. kss 나 kiwi 같은 한국어 형태소 분석기를 들여와 진짜 문장 경계를 찾는 길. 단, 의존성이 무겁고 학습용으로는 과해요. 두 번째 후보가 영어 4종 하드코딩 지점을 한국어 패밀리로 늘리는 가벼운 우회예요. 본 강의는 후자를 골랐어요.
먼저 한국어 종결 마크 패밀리를 상수로 정의했어요.
// kr.spartaclub.aifriends.rag.service.DocumentLoaderService
public static final List<Character> KOREAN_PUNCTUATION_MARKS = List.of(
'.', '?', '!', '\n', '。', '?', '!', '·');
영어 4종 (., ?, !, \n) 위에 한자 마침표 패밀리 (。, ?, !) 와 한국어 권점 (·) 을 더한 8 종이에요. 한자 마침표는 한국어 문서에 의외로 자주 섞여요 (특히 옛 문헌이나 일본 번역물). 권점은 한국어 단어 구분 지점으로 종종 등장하는데, 청크 절단면을 한 단어 더 자연스럽게 만들어줘요.
이제 본격적인 우회 과정이에요. Spring AI 의 TokenTextSplitter 는 builder 도 제공하는데, 그 builder 가 withChunkSize, withMinChunkSizeChars 같은 메서드는 노출하면서, withPunctuationMarks 는 없어요. 즉, 영어 4종 하드코딩 지점을 외부에서 갈아끼울 수 있는 API 가 builder 에 없다는 뜻이에요.
🙋 학생 질문 — "튜터님, builder 에 옵션이 없으면 그냥 외부 라이브러리 (kss/kiwi) 를 쓰면 되지 않나요? 굳이 서브클래싱이라는 우회를 하는 이유가 뭐죠?"
비용·복잡도의 트레이드오프예요. 두 카테고리를 비교해 볼게요.
외부 라이브러리 (kss / kiwi) 도입 분기 — 한국어 문장 분할 정확도는 가장 높아요. 형태소 분석으로 진짜 문장 경계를 찾으니까요. 단, 의존성이 무거워요 (kiwi 는 사전 파일만 100MB+). 빌드 시간 늘어나고, JVM 메모리도 잡아먹어요. 또 Spring AI 의 TokenTextSplitter 줄기와 다른 추상화라, 둘을 조립하는 접착 코드가 따로 필요해요.
서브클래싱 길 — 우리가 택한 방식이에요. 부모 클래스의 분할 알고리즘은 그대로 두고, 종결 마크 후보만 교체해요. 의존성 증가 0, 코드 90 줄, JVM 부담 0. 정확도는 형태소 분석보다 떨어지지만, 한국어 평문 KB 에는 충분한 결과를 주고요.
학습용에선 후자가 분명히 유리해요. 외부 라이브러리 없이 라이브러리의 한계를 우회하는 형 자체가 실무에서 자주 패턴이기도 하고요. 그리고 본 우회는 영구적인 것도 아니에요. Spring AI 2.0 이 punctuationMarks 옵션을 공식 노출하면 (이미 GitHub 이슈에 올라온 상태) 본 서브클래스는 자연스럽게 사라져요. 한시적 우회예요.
해결책은 TokenTextSplitter 를 서브클래싱하는 방식이에요. 부모의 분할 알고리즘 (토큰화 → 청크 자르기 → 종결 마크 찾기) 의 큰 길은 그대로 두고, 종결 후보 마크 찾기 로직만 punctuationMarks 기반으로 재구현해요. 클래스 이름은 KoreanTokenTextSplitter. 핵심 발췌를 보여드릴게요.
// kr.spartaclub.aifriends.rag.service.DocumentLoaderService.KoreanTokenTextSplitter
// 전체 코드: lecture-source-code/.../rag/service/DocumentLoaderService.java L140~L230
// 종결 마크 후보 중 가장 늦은 자리 — 영어 4종 + 한국어/한자 패밀리
int lastPunctuation = -1;
for (Character mark : this.punctuationMarks) {
int idx = chunkText.lastIndexOf(mark);
if (idx > lastPunctuation) {
lastPunctuation = idx;
}
}
if (lastPunctuation != -1 && lastPunctuation > this.minChunkSizeChars) {
chunkText = chunkText.substring(0, lastPunctuation + 1);
}
핵심은 두 줄이에요. 첫째, punctuationMarks 리스트를 한 바퀴 돌면서 청크 텍스트 안에서 가장 늦게 등장하는 마크 인덱스를 찾아요. lastIndexOf 가 마크별 마지막 등장 위치를 알려주고, 그 중 가장 큰 값을 골라요. 여기만 다르면 나머지 분할 알고리즘은 부모와 동일해요.
둘째, 그 인덱스가 minChunkSizeChars 보다 큰 지점에 있어야만 거기서 끊어요. 너무 일찍 만난 마침표 (위에서 정정한 그 안전선) 는 무시하는 절차이에요. 두 발견이 한 지점에서 만나는 구조죠.
그리고 이 서브클래스는 한시적이에요. Spring AI 2.0 이 punctuationMarks 옵션을 공식 노출하면, 본 클래스는 자연스럽게 사라져요. 그때는 builder 한 줄로 같은 효과를 낼 수 있으니까요. 학습용에선 "라이브러리가 안 노출하면 우리가 가볍게 한 번 우회하면 된다" 의 골격을 이해하는 단계예요.
본격 오버로드 — 학생이 직접 조절하는 chunk 크기
이제 우리는 두 발견을 묶어서, 학생이 청크 크기를 외부에서 직접 만질 수 있는 오버로드를 추가해요. 시그니처는 chunk(List, int, int, List<Character>). 청크 크기, 최소 문자 수, 종결 마크 리스트 세 가지를 호출 시점에 받아요.
// kr.spartaclub.aifriends.rag.service.DocumentLoaderService
public List<Document> chunk(List<Document> rawDocuments,
int chunkSize,
int minChunkSizeChars,
List<Character> punctuationMarks) {
TokenTextSplitter splitter = new KoreanTokenTextSplitter(
chunkSize, minChunkSizeChars, punctuationMarks);
List<Document> chunks = splitter.apply(rawDocuments);
log.info("[RAG] split {} raw documents into {} chunks (chunk={}, minChunkSizeChars={}, marks={})",
rawDocuments.size(), chunks.size(), chunkSize, minChunkSizeChars, punctuationMarks);
return chunks;
}
세 인자를 받아 KoreanTokenTextSplitter 세트를 만들고, 그 위에 apply 한 줄을 돌리는 과정이에요. log.info 한 줄이 몇 개 청크가 어떤 옵션으로 나왔는지를 운영 로그로 흘려요. 학습 단계에선 이 로그를 눈으로 보면서 청크 수의 변화를 체감하실 수 있어요.
이 오버로드의 가치는 한 가지예요. 학생이 자기 KB 에 맞춰 청크 크기를 만져보는 통로가 열렸다는 것. 200 으로 자르고 검색해보고, 1000 으로 자르고 검색해보고, marks 를 영어 4종으로만 줄여서 한자 마침표 무시되는 장면도 확인해보고. 트레이드오프를 직접 체감할 수 있는 통로가 열렸어요.
호출 예시 두 가지 시연해드릴게요.
// 청크 크기 200 으로 실험
loaderService.chunk(rawDocs, 200, 100, DocumentLoaderService.KOREAN_PUNCTUATION_MARKS);
// 영어 marks 만으로 비교 — 한자 마침표 자리에서 안 끊김
loaderService.chunk(rawDocs, 500, 350, List.of('.', '?', '!', '\n'));
위 호출은 청크 크기 200 으로 작게 자르는 옵션. 청크 수가 13 개 정도로 많이 나와요. 아래 호출은 영어 marks 만으로 자르는 비교 실험. 한국어 KB 라면 한자 마침표가 무시되는 현상을 직접 확인할 수 있어요.
🎯 Step 2 Before/After 비교 —
DocumentLoaderService.chunk()in-place 진화
축 Before (Day 15) After (Day 16 Step 2) chunk()시그니처chunk(List)단일, 모든 파라미터 상수 하드코딩chunk(List)(기본) +chunk(List, int, int, List<Character>)본격 오버로드청크 크기 외부 노출 없음 — 학생이 만지려면 상수 수정 + 컴파일 호출 시점에 chunkSize인자 직접 지정분할 마크 영어 4종 하드코딩 ( .,?,!,\n)한국어 8종 ( KOREAN_PUNCTUATION_MARKS) + 학생이 List 자체를 갈아끼움두 번째 인자 이름 DEFAULT_CHUNK_OVERLAP = 50(잘못된 이름)DEFAULT_MIN_CHUNK_SIZE_CHARS = 350(정정)Splitter클래스TokenTextSplitter5인자 생성자 직접KoreanTokenTextSplitter서브클래스 (punctuationMarks 우회)호출자 호환 — Day 15 의 IngestionService·EmbeddingProbeController코드 한 줄 안 바뀜
이 표를 보세요. 메서드 시그니처는 그대로 유지하고 내부만 갈아끼우는 과정이에요. 새 lab 을 따로 만들지 않고, 기존 메서드를 정정하면서 옵션을 늘려놓은 구조예요. Day 16 마무리 시점에 이 정정된 DocumentLoaderService 가 그대로 prod 적재 흐름에 그대로 적용돼요. 별도 수렴 시점이 더 필요 없는, 한 Step 안에서 흡수가 완료되는 방식이에요.
💡 튜터의 결론
청크 전략은 "내 KB + 내 질문 구조" 의 조합으로 정해져요. 정답이 하나가 아니라 트레이드오프예요. 본 강의가 설정한 500 / 350 / 한국어 8종 marks 는 한국어 인물 프로필 KB 에서 무난한 균형점일 뿐이고, 코드 스니펫 KB 면 200, 긴 논문 KB 면 1000 으로 갈아끼워보는 게 자연스러워요. 학생이 직접 숫자를 만지면서 내 KB 에 맞는 균형점을 찾는 통로가 오늘 열린 가장 큰 가치예요.
자, 청크의 재료를 깎는 절차까지 파악됐어요. 그런데 우리가 지금까지 다룬 KB 는 .md / .txt 평문이었어요. 실무에서는 .pdf, .docx, .html 같은 형식도 흔하죠. 다음 Step 에서는 이 형식들을 한 번에 흡수하는 TikaDocumentReader 옵션을 다뤄요.
Step 3. TikaDocumentReader — 형식 만능 흡수 어댑터
Step 2 에서 청크 크기·최소 문자 수·한국어 마크의 트레이드오프를 다뤘죠. 청크를 잘 자르는 부분까지는 좋아졌는데, 한 가지 한계가 남아 있어요. 우리가 자르는 원문 자체가 .md / .txt 평문에만 머물러 있다는 구조죠. 학생 KB 에 PDF 회의록이나 DOCX 캐릭터 설정집도 들어올 수 있어야, 진짜 실무 KB 의 호흡으로 자랄 수 있어요.
이번 Step 에서는 그 형식 확장을 다뤄요. Spring AI 가 형식별로 제공하는 DocumentReader 풀을 한 번에 펼쳐 보고, 본 강의가 어떤 것을 prod 에 채택했는지 이유와 함께 짚어요. 그 다음, 평문 라인과 바이너리 라인을 어떻게 깔끔하게 분기하는지 룰 한 형태을 잡아 둬요.
DocumentReader 풀 — 형식별 어댑터 비교
Spring AI 1.1.x 는 형식별로 DocumentReader 를 분리해서 제공해요. 각각 별도 Maven artifact 라, 내가 받아 들일 형식에 맞춰 의존성을 추가하는 흐름이에요. 본 강의가 마주칠 후보를 한 표로 펼쳐 볼게요.
| 드라이버 | Maven artifact | 적합 용도 | 본 강의 채택 |
|---|---|---|---|
TextReader / StreamUtils |
(core) | .md .txt 평문 |
✅ (기본) — 가볍고 충분 |
JsonReader |
(core) | .json 구조화 |
(해당 없음) |
MarkdownDocumentReader |
spring-ai-markdown-document-reader |
.md 의 헤더 단위 분할 |
❌ — 본 강의는 평문 한 번에 충분 |
PagePdfDocumentReader |
spring-ai-pdf-document-reader |
PDF 페이지 단위 | ❌ — 본 강의는 Tika 하나로 |
ParagraphPdfDocumentReader |
spring-ai-pdf-document-reader |
PDF 문단 카탈로그 단위 | ❌ — 동상 |
JsoupDocumentReader |
spring-ai-jsoup-document-reader |
.html 의 CSS 선택자 단위 |
❌ — Tika 가 HTML 도 흡수 |
TikaDocumentReader |
spring-ai-tika-document-reader |
PDF/DOC/PPT/HTML/XLSX/RTF/EPUB 등 50+ | ✅ (prod 채택) |
표가 길어 보이지만 과정는 단순해요. 형식별로 어댑터가 따로 있고, 어떤 어댑터는 같은 형식을 더 정밀한 단위로 잡아줘요. 예를 들어 PagePdfDocumentReader 는 PDF 한 권을 페이지 단위 Document 로 만들어 주고, ParagraphPdfDocumentReader 는 더 작은 문단 카탈로그 단위로 떼어 줘요. 정밀 도메인 (논문 인용 추적, 법률 챕터별 검색) 에서는 이 정밀도가 의미를 가져요.
본 강의는 후보 중 TikaDocumentReader 하나로 통일해요. 이유는 한 줄 — 학생이 형식별로 어댑터를 갈아끼우는 줄기보다, 형식을 신경 안 쓰고 한 번에 받아 들이는 방식이 학습에 맞아요. 고민의 카테고리가 적은 쪽이 학습용에 자연스럽고, Tika 하나가 PDF·DOCX·PPTX·HTML·XLSX·RTF·EPUB 같은 주요 형식을 자동으로 인식하니까요.
🙋 학생 질문 — "튜터님, PDF 전용 reader 가 더 정밀하다고 하셨는데 왜 본 강의는 Tika 를 쓰나요?"
정밀도와 학습 호흡의 트레이드오프예요.
PagePdfDocumentReader 같은 PDF 전용 어댑터는 PDF 의 페이지 구조를 그대로 보존해서 Document 단위로 떼어 줘요. 페이지 번호를 metadata 로 기록해주기도 하고요. 이 정밀도가 가치를 가지는 도메인은 정해져 있어요. 논문 인용 추적 (어느 페이지의 어느 문단인지), 법률 문서 챕터별 검색 (제 N 조 N 항이라는 단위가 의미를 가짐), 보고서 페이지별 인덱싱 같은 도메인이죠.
본 강의 미연시 캐릭터 KB 는 정밀 도메인이 아니에요. ARIA 의 세계관 설정집, 캐릭터 프로필, 마스터와의 어제 약속 메모 — 이런 평문은 페이지가 어디서 시작되고 끝나는지보다, 본문 텍스트가 통째로 들어오는 게 자연스러워요. Tika 가 추출한 평문이 작은 KB 에 더 잘 맞고, 호출자도 형식별로 분기하는 코드를 안 짜도 돼요.
또 한 가지 — 다음 Step 에서 다룰 metadata 필터로 캐릭터별 KB 를 분리할 때도, Tika 가 제공하는 단순한 metadata 키가 본 강의 절차에 더 깔끔해요. PDF 전용 reader 의 페이지 metadata 가 오히려 군더더기가 될 수 있죠.
spring-ai-tika-document-reader 의존성 한 줄
Tika 어댑터도 별도 artifact 에 들어 있어요. Spring AI 가 형식별로 Maven artifact 를 분리해 두었으니, 우리 KB 에 필요한 한 줄만 추가하면 돼요.
// lecture-source-code/ai-friends/build.gradle (L70)
implementation 'org.springframework.ai:spring-ai-tika-document-reader'
이 한 줄로 TikaDocumentReader 가 따라와요. Tika 의 50+ 파서 모듈도 함께 받아 들이는데, 빌드 도구가 ./gradlew bootRun 직전에 의존성을 한 번 받아주면 다음 단계로 넘어갈 수 있어요. 의존성 표면이 살짝 무거워지는 면은 있지만 (Tika core + parser 모듈이 합쳐 30MB+ 정도), 학습용 KB 에는 큰 부담이 아니에요.
loadRawWithTika — Tika 어댑터 한 줄
이제 Tika 어댑터를 어떻게 호출하는지 살펴볼게요. DocumentLoaderService 안에 한 메서드를 추가해요. 시그니처는 Resource 한 장을 받아 List<Document> 를 돌려주는 구조예요.
// kr.spartaclub.aifriends.rag.service.DocumentLoaderService
public List<Document> loadRawWithTika(Resource resource) {
TikaDocumentReader reader = new TikaDocumentReader(resource);
List<Document> docs = reader.get();
log.info("[RAG] Tika loaded {} document(s) from resource={}", docs.size(), resource.getFilename());
return docs;
}
핵심은 한 줄짜리 호출이에요. new TikaDocumentReader(resource).get() 한 세트가면 Tika 가 MIME 을 스스로 감지하고 텍스트만 추출해서 돌려줘요. 호출자는 그 resource 가 PDF 인지 DOCX 인지 신경 쓸 필요가 없어요. 어댑터 하나가 모든 형식을 흡수하는 구조죠.
log.info 한 줄이 몇 개 Document 가 어디서 나왔는지를 운영 로그로 흘려요. Tika 는 형식에 따라 한 파일을 한 Document 로 펼치는 방식이라, 보통 size 가 1 로 찍혀요. 다만 PDF 같은 일부 형식은 페이지를 한 번에 합치는 옵션도 있고, 그 옵션을 갈아끼우면 size 가 달라질 수 있어요.
확장자 분기 — loadRawAuto 와 PLAIN_TEXT_EXTENSIONS
Tika 어댑터 하나로 모든 형식을 받아 들일 수 있다지만, 평문 .md / .txt 까지 Tika 로 돌리는 건 살짝 낭비예요. Tika 가 초기 로딩 단계에서 50+ 파서 모듈을 검색하면서 1~4 초가 걸리거든요. 평문은 그냥 StreamUtils 한 줄로 읽어 들이면 0.01 초도 안 걸리는 작업인데, 무거운 어댑터를 깔지 않아도 충분해요.
그래서 확장자를 보고 어댑터를 분기하는 과정을 짰어요. 평문 확장자 집합을 상수로 정의하고, 그 집합에 속한 확장자는 가벼운 라인으로, 그 외는 Tika 라인으로 흘려요.
// kr.spartaclub.aifriends.rag.service.DocumentLoaderService
private static final Set<String> PLAIN_TEXT_EXTENSIONS = Set.of("md", "txt");
public List<Document> loadRawAuto(Resource resource) {
String filename = resource.getFilename();
String ext = extractExtension(filename);
if (PLAIN_TEXT_EXTENSIONS.contains(ext)) {
return loadPlainSingle(resource);
}
return loadRawWithTika(resource);
}
흐름이 한눈에 들어와요. 파일명에서 확장자를 떼어내고, 그 확장자가 평문 집합에 속하면 loadPlainSingle 라인 (StreamUtils 기반) 으로, 그 외면 Tika 라인으로 분기하는 구조죠. 확장자가 없거나 인식 못 하는 경우는 안전하게 Tika 쪽으로 넘기는 방식이에요. Tika 자체가 못 읽는 형식이면 빈 본문 Document 를 돌려주니까, 호출자는 결과 비어 있음만 한 번 체크하면 충분해요.
이 구조의 본질은 한 줄로 정리할 수 있어요. 형식에 맞춰 어댑터를 갈아끼우는 분기 지점. 새 형식이 KB 에 추가될 때 (예: 음성 메모 .mp3 의 STT 결과 텍스트가 필요해지면), 같은 분기 지점에 새 어댑터 라인 한 줄만 끼우면 돼요. 호출자(loadAndChunk · CharacterKnowledgeIngestionService) 는 어떤 어댑터가 골라졌는지 모른 채 결과만 받아 들이는 절차이에요.
🙋 학생 질문 — "튜터님, 왜 기존 `loadRaw()` 메서드를 직접 자동 분기로 바꾸지 않고 새 메서드 `loadRawAuto` 를 만들었나요?"
호환성 보존이 핵심이에요. 점진 리팩토링의 골격이라고 보시면 돼요.
Day 15 에서 박은 loadRaw() 는 classpath 의 .md / .txt 파일을 한 번에 흘리는 시그니처예요. 이 메서드를 부르는 호출자가 두 군데 있어요 — CharacterKnowledgeIngestionService 의 적재 사이클, 그리고 EmbeddingProbeController 의 디버그 엔드포인트. 두 호출자 모두 Day 15 에 만들어진 코드라, 시그니처가 바뀌면 빌드부터 깨져요.
loadRaw() 를 직접 자동 분기로 바꾸려면 시그니처가 List<Document> loadRaw(Resource) 같은 방식으로 자라야 하는데, 그 순간 호출자가 깨져요. 게다가 기존 동작 (classpath 구조 매칭) 의 의미도 흐려지죠.
새 메서드 loadRawAuto(Resource) 를 하나 더 추가하면 기존 loadRaw() 는 그대로 살아 있고, 새 진입점만 옆에 자라요. Day 15 호출자는 코드 한 줄 안 바꿔도 계속 동작해요. 다음 시간 (Day 17 이후) 에 호출자 측이 단일 Resource 단위로 발전하면, 그때 자연스럽게 loadRawAuto 로 옮겨가는 길이 열려요.
본 강의의 점진 리팩토링이 이렇게 동작해요 — 시그니처를 깨지 않으면서 기능을 옆으로 자라게 두는 과정. lab → prod 흡수의 변종이라고 보시면 돼요.
💡 튜터의 결론
형식 확장이 어댑터 한 줄 추가로 끝나는 구조가 갖춰졌어요. Spring AI 가 형식별로 라이브러리를 분리해 둔 덕분에, 우리가 내가 짤 코드량은 6 줄 (Tika 어댑터 호출) + 10 줄 (확장자 분기) 정도예요. 라이브러리가 잡고 있는 형식 풀을 잘 활용하면 우리가 신경 쓸 부분이 줄어드는 게 본 룰의 가장 큰 값이에요.
Before/After — DocumentLoaderService in-place 형식 확장
같은 클래스 안에서 호환성을 유지하면서 형식 풀을 늘려둔 흐름이에요. 한 표로 정리해볼게요.
| 축 | Before (Day 15~Step 2) | After (Day 16 Step 3) |
|---|---|---|
| 지원 형식 | .md / .txt 만 |
+ PDF/DOC/PPT/HTML/XLSX/RTF/EPUB 등 50+ |
| 로더 진입 | loadRaw() (배치) |
loadRaw() + loadRawAuto(Resource) (단일 분기) |
| 어댑터 선택 | 하드코딩 (StreamUtils) |
확장자 분기 (plain vs Tika) |
| 새 형식 추가 비용 | 메서드 한 세트 새로 짜기 | 0 (Tika 가 자동 인식) |
metadata source 키 |
"source" 평문 키 |
plain 라인 "source" / Tika 라인 TikaDocumentReader.METADATA_SOURCE (별도) |
| 호출자 호환 | — | ✅ (기존 loadRaw() 그대로) |
이 표의 마지막 줄이 핵심이에요. Day 15 의 CharacterKnowledgeIngestionService 도, EmbeddingProbeController 도 코드 한 줄 안 바뀌고 그대로 돌아가요. 새 진입점 loadRawAuto 가 옆에 자랐고, 기존 진입점은 그대로 살아 있는 길이죠. 다음 시간 호출자 측 진화가 익으면 자연스럽게 새 진입점으로 옮겨갈 길이 열려 있어요.
metadata source 키 두 분기 — Step 4 다리
위 표의 다섯 번째 줄이 의미심장한 부분이에요. plain 라인은 source 라는 평문 키에 파일명을 기록하고, Tika 라인은 TikaDocumentReader.METADATA_SOURCE 라는 별도 상수 키에 기록해요. 일부러 분리한 절차이에요.
이유는 한 줄 — 어떤 어댑터에서 온 건지 추적할 수 있도록. 같은 KB 안에 평문과 PDF 가 섞여 있을 때, 검색 결과를 받아 보면 어느 라인에서 흘러나온 청크인지 metadata 만으로 알아볼 수 있어요. 디버깅에도 좋고, 더 중요한 건 다음 Step 으로 이어지는 다리 역할이에요.
다음 Step (metadata 필터) 에서 이 두 metadata 키가 어떻게 캐릭터별 검색에 활용되는지 파악돼요. 같은 pgvector 한 통 안에서 ARIA 전용 KB 와 HARU 전용 KB 가 어떻게 분기되는지의 핵심 부품이거든요. metadata 키 분리가 단순한 운영 편의를 넘어, 검색 필터의 본격적인 기반이 돼요.
코드베이스 DocumentLoaderServiceTest 가 .md 평문 로드, HTML 태그 스트립, 확장자 fallback 같은 5 가지 케이스를 미리 검증해 두었어요. 학생이 손으로 PDF 한 장을 character-knowledge/ 폴더에 넣어 보고, loadRawAuto 호출 결과의 metadata 를 눈으로 확인하면 본 Step 의 과정이 한눈에 확실히 이해될 거예요.
자, 형식 만능 흡수 어댑터까지 박혔어요. 이제 같은 pgvector 한 통 안에 ARIA 전용 KB 와 HARU 전용 KB 가 함께 들어 있을 때, 검색 단계에서 캐릭터별로 어떻게 분기하느냐는 질문이 남아 있죠. 다음 Step 에서는 metadata 필터와 Supplier<FilterExpression> 형으로 그 분기를 자동화하는 장면을 다뤄요.
Step 4. metadata 필터 — 같은 pgvector 안에서 캐릭터별 KB 분리
Step 3 에서 Tika 어댑터로 형식 풀을 넓혔죠. PDF · DOCX · PPTX 까지 한눈에 받아 들이는 구조가 갖춰졌어요. 그런데 한 가지 한계가 남아 있어요. 같은 pgvector 한 통 안에 ARIA 의 프로필, HARU 의 일정 메모, 공용 세계관 청크가 다 섞여 있다는 점이에요. 학생이 ARIA 와 대화 중인데 HARU 의 비밀이 검색에 끌려오면 곤란하죠. 캐릭터 색채가 흐려져요.
학생 KB 가 자라기 시작하면 캐릭터별 분리는 필수 흐름이에요. 별도 pgvector 테이블을 N 벌 만드는 후보도 있긴 한데, 비용도 크고 운영 표면도 N 배로 자라요. 같은 한 통 안에 두고 metadata 한 칸으로 분기하는 게 자연스러운 첫 통로예요. 본 Step 에서는 그 metadata 분기를 살펴봐요.
청크에 character_id metadata 박기
먼저 적재 시점에 청크마다 어느 캐릭터의 KB 인지를 표시해 둬야 해요. 본 강의는 파일명 컨벤션을 분류 규칙으로 써요 — aria-profile.md 의 첫 토큰 aria 가 곧 ARIA, haru-events.md 의 첫 토큰 haru 가 곧 HARU, world-lore.md 같은 공용 자료는 COMMON 으로 분류하는 구조예요. 적재 흐름 안에 이 로직을 끼워 넣어요.
// kr.spartaclub.aifriends.rag.service.CharacterKnowledgeIngestionService
public int ingest() {
List<Document> chunks = documentLoaderService.loadAndChunk();
for (Document chunk : chunks) {
attachCharacterIdMetadata(chunk);
}
vectorStore.add(chunks);
log.info("[RAG] ingested {} chunks into vector store", chunks.size());
return chunks.size();
}
private void attachCharacterIdMetadata(Document chunk) {
Object sourceObj = chunk.getMetadata().get("source");
String source = sourceObj == null ? "" : sourceObj.toString();
String characterId = resolveCharacterId(source);
chunk.getMetadata().put("character_id", characterId);
}
static String resolveCharacterId(String sourceFilename) {
if (sourceFilename == null || sourceFilename.isBlank()) {
return "COMMON";
}
String firstToken = sourceFilename.split("[-.]", 2)[0]
.toUpperCase(Locale.ROOT);
if (firstToken.isEmpty() || "WORLD".equals(firstToken)) {
return "COMMON";
}
return firstToken;
}
흐름이 간단해요. ingest() 가 청크 목록을 받아 오면, vectorStore.add(chunks) 직전에 청크 하나하나에 character_id 키를 추가하는 거죠. resolveCharacterId 가 파일명의 - 또는 . 앞 부분을 떼어 대문자로 올리고, world 만 COMMON 으로 매핑하는 단순 규칙이에요. 그 외 첫 토큰은 그대로 캐릭터 ID 가 돼요.
이 단순한 규칙이 본 강의 학습 줄기에 잘 맞아요. 학생이 새 캐릭터 MINA 를 추가하고 싶으면 mina-profile.md 한 장을 character-knowledge/ 폴더에 넣고 ingest 한 번 다시 돌리면 끝이에요. 코드 한 줄 안 고쳐도 돼요. 파일명만 잘 지정하면 분류가 자동으로 따라오는 구조죠.
🙋 학생 질문 — "튜터님, 파일명 추론이 그렇게 단순한데 운영에서 안전한가요?"
본 강의의 lab 에서는 단순함이 큰 값이지만, 실제 prod 에서는 더 단단한 옵션으로 자라야 해요.
이유는 두 가지예요. 첫째, 파일명은 사람이 직접 작성하는 거라 오타가 자주 나요. aria-profile.md 가 airia-profile.md 가 되면 그 청크는 AIRIA 라는 새 캐릭터로 분류되어 검색에서 떨어져 나가요. 둘째, 한 파일 안에 여러 캐릭터의 내용이 섞여 있는 자료 (예: 캐릭터끼리 대화하는 시나리오) 는 파일명만으로는 분류할 수 없어요.
prod 운영에서는 두 가지 카테고리가 자연스러워요. 하나는 frontmatter — 마크다운 파일 첫머리에 ---\ncharacter_id: ARIA\n--- 같은 YAML 블록을 넣고 파서가 그걸 읽어 metadata 로 옮기는 방식. 다른 하나는 manifest.yml — 별도 매니페스트 파일에 "이 파일은 이 캐릭터, 저 파일은 저 캐릭터" 목록을 박고 ingest 시점에 매니페스트를 조회하는 방식이에요.
본 강의는 학습 메시지 (metadata 필터의 본질) 를 명확히 전하기 위해 파일명 하나로 단순화했어요. 학생이 직접 KB 를 운영할 때는 위 두 가지 중 하나로 전환하는 것을 고려해두시면 돼요.
두 후보 표현 — 텍스트식 vs 프로그래매틱
metadata 가 박혔으면 이제 검색 시점에 그 키를 보고 필터를 거는 단계가 따라와요. Spring AI 의 VectorStore 검색 API 는 필터 표현을 두 옵션으로 받아들여요.
// 텍스트식 — 짧고 SQL 같은 느낌
SearchRequest.builder()
.query(query)
.topK(3)
.filterExpression("character_id == 'ARIA'")
.build();
// 같은 식을 IN 으로 확장하면
.filterExpression("character_id in ['ARIA', 'COMMON']")
// 프로그래매틱 — 변수 합성 안전, IDE 자동완성 (본 강의 prod 채택)
FilterExpressionBuilder b = new FilterExpressionBuilder();
SearchRequest.builder()
.query(query)
.topK(3)
.filterExpression(b.eq("character_id", "ARIA").build())
.build();
// 같은 식을 IN 으로 확장하면
.filterExpression(b.in("character_id", "ARIA", "COMMON").build())
두 방식의 차이가 한눈에 보이죠. 텍스트식은 SQL 같은 느낌으로 짧고 가독성이 좋아요. 학습용 README 나 디버그 쿼리에 쓰기 좋아요. 다만 캐릭터 ID 가 사용자 입력에서 흘러 들어오는 경우에는 문자열 합성 ("character_id == '" + character + "'") 의 인젝션 위험이 자연스럽게 따라와요. 따옴표 escape 도 손으로 챙겨야 하고요.
프로그래매틱은 빌더 객체가 따옴표와 escape 를 자동으로 챙겨줘요. 컴파일러가 메서드 시그니처를 검사해 주고, IDE 자동완성도 따라오죠. 본 강의 prod 코드는 프로그래매틱 길을 채택해요. 학습용 lab 디버깅에서는 텍스트식이 빠르게 파악할 수 있는 면도 있어, 두 방식을 함께 익혀두면 파악할 수 있는 도구가 늘어나요.
lab 검색 엔드포인트 — character 옵셔널 파라미터
두 후보의 차이를 학생이 직접 손으로 확인할 수 있도록, Day 15 의 검색 엔드포인트에 캐릭터 파라미터를 한 줄 추가해요.
// kr.spartaclub.aifriends.rag.controller.CharacterKnowledgeController
@GetMapping("/search")
public ResponseEntity<ApiResponse<Map<String, Object>>> search(
@RequestParam("q") String query,
@RequestParam(defaultValue = "3") int topK,
@RequestParam(value = "character", required = false) String character) {
SearchRequest.Builder requestBuilder = SearchRequest.builder().query(query).topK(topK);
if (character != null && !character.isBlank()) {
requestBuilder.filterExpression(new FilterExpressionBuilder()
.eq("character_id", character)
.build());
}
List<Document> hits = vectorStore.similaritySearch(requestBuilder.build());
// ... hit payload 매핑 후 응답
}
character 가 옵셔널 파라미터로 들어와요. 값이 들어오면 FilterExpressionBuilder.eq 한 줄로 필터를 박고, 값이 비어 있으면 옛 동작 (전체 KB 검색) 그대로 흘리는 절차이에요. 빈 문자열도 안전하게 없음 으로 간주해서 호출자가 빈 값으로 호출해도 깨지지 않아요.
학생이 직접 손으로 비교해 볼 수 있어요. 같은 pgvector 한 통 안에서 같은 질문으로 세 옵션 호출을 돌려 보면 결과가 어떻게 갈리는지 한눈에 파악돼요.
GET /search?q=성격&topK=3&character=ARIA → 2 hits, 모두 ARIA
GET /search?q=성격&topK=3&character=HARU → 2 hits, 모두 HARU
GET /search?q=성격&topK=5 (필터 없음) → 5 hits 혼합 (2 ARIA + 1 HARU + 2 COMMON)
같은 pgvector 한 통 안에서 metadata 필터 한 줄로 캐릭터별 검색이 분기되는 그림이 파악돼요. 저장소를 쪼개는 게 아니라, 검색 질문을 좁히는 사이클이라는 본질이 한눈에 들어와요.
🙋 학생 질문 — "튜터님, advisor 빈을 캐릭터마다 N 벌 만들면 안 되나요? 왜 동적 supplier 골격이 필요한가요?"
빈 N 벌 방식의 비용 문제가 핵심이에요.
advisor 빈을 캐릭터마다 따로 등록하는 방식을 상상해 볼게요. ariaKbAdvisor, haruKbAdvisor, minaKbAdvisor... 새 캐릭터가 추가될 때마다 RagAdvisorConfig 에 빈 정의가 따라 추가돼요. 등록 시점에 캐릭터 ID 가 고정되어 버리니 호출 시점에 바꿀 수 없고, ChatClient 쪽에서도 "어떤 캐릭터인지 보고 어떤 advisor 를 골라 끼우느냐" 의 분기 코드가 추가돼요. 등록 표면이 N 배로 부풀어요.
동적 supplier 구조는 한 장의 advisor 빈을 등록해 두고, 호출 시점마다 그 순간의 캐릭터 ID 를 읽는 방식이에요. 빈은 한 번만 빌드되지만 supplier 함수는 검색 호출마다 다시 평가돼요. ARIA 와 대화 중이면 ARIA 필터로, HARU 와 대화 중이면 HARU 필터로 — 같은 빈 하나가 N 캐릭터를 자동으로 커버하는 단계죠.
새 캐릭터가 추가될 때 추가 작업이 0 이에요. 적재 시점에 character_id metadata 만 제대로 들어 있으면, advisor 빈은 그대로 두고도 자연스럽게 새 캐릭터의 검색을 처리해요. 운영 표면을 좁게 두는 분기라고 보시면 돼요.
동적 supplier 룰 — advisor 하나가 N 캐릭터를 커버
이제 prod 진입점 (RAG advisor) 으로 들어가요. 위 lab 엔드포인트가 검색마다 정적 필터를 거는 방식이라면, ChatClient 의 advisor 는 서비스 진입점이 알 필요 없이 호출 시점마다 자동으로 필터가 적용되는 구조가 필요해요. 여기에 Supplier<Filter.Expression> 형이 들어와요.
// kr.spartaclub.aifriends.rag.config.RagAdvisorConfig
@Bean
public RetrievalAugmentationAdvisor characterKbAdvisor(VectorStore vectorStore) {
return RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(DEFAULT_SIMILARITY_THRESHOLD)
.topK(DEFAULT_TOP_K)
// 동적 supplier — 호출 시점마다 ThreadLocal 의 캐릭터 ID 를 새로 읽어
// character_id IN ['<현재 캐릭터>', 'COMMON'] 필터를 박는다.
.filterExpression(RagAdvisorConfig::currentCharacterFilter)
.build())
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(false)
.build())
.order(0)
.build();
}
static Filter.Expression currentCharacterFilter() {
String characterId = CharacterContextHolder.current();
if (characterId == null || characterId.isBlank()) {
return null; // 필터 없음 → VectorStore 가 전체 KB 검색
}
return new FilterExpressionBuilder()
.in("character_id", characterId, "COMMON")
.build();
}
핵심은 .filterExpression(RagAdvisorConfig::currentCharacterFilter) 한 줄이에요. filterExpression 에 정적 표현 이 아닌 메서드 참조 (Supplier) 를 전달한 거죠. advisor 빈은 Spring 컨테이너 기동 시점에 한 번 빌드되어 ChatClient 전역에 등록되지만, 검색 호출이 일어날 때마다 supplier 가 다시 평가되어 그 시점의 캐릭터 ID 를 읽어가는 흐름이에요.
currentCharacterFilter 의 본문도 흐름이 단순해요. CharacterContextHolder.current() 로 현재 스레드의 캐릭터 ID 를 꺼내고, 비어 있으면 null 을 돌려 필터 없음 = 전체 KB 검색 으로 fallback 해요. 값이 들어 있으면 FilterExpressionBuilder.in 으로 현재 캐릭터 + COMMON 두 길을 한 번에 넣어요. 캐릭터 색채 (ARIA 전용 청크) 와 세계관 일관성 (공용 청크) 을 동시에 잡는 구조죠.
CharacterContextHolder — 학습용 ThreadLocal lab
supplier 가 어디서 캐릭터 ID 를 읽어 오느냐가 다음 질문이에요. 본 강의는 학습용으로 단순하게 ThreadLocal 을 사용했어요.
// kr.spartaclub.aifriends.rag.config.CharacterContextHolder
public final class CharacterContextHolder {
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
private CharacterContextHolder() {
// 유틸리티 클래스 — 인스턴스화 금지
}
public static void set(String characterId) {
CURRENT.set(characterId);
}
public static String current() {
return CURRENT.get();
}
public static void clear() {
CURRENT.remove();
}
}
Spring MVC 의 한 요청 = 한 스레드 모델에서 가장 가볍게 호출 시점의 컨텍스트 를 잡는 후보예요. 컨트롤러 진입 시점에 set 하고, 응답 직전에 clear 하는 호흡이면 supplier 가 그 사이 호출되는 시점마다 정확한 캐릭터 ID 를 받아 가요.
다만 ThreadLocal 은 메모리 누수 위험이 따라와요. 같은 스레드가 다음 요청에 재사용되는데 앞 요청이 clear 를 안 했으면, 다음 요청의 supplier 가 앞 요청의 캐릭터 ID 를 읽어 가요. 호출자가 try { set(...); ... } finally { clear(); } 패턴을 지킬 책임이 따라와요. WebFlux/Reactor 환경에서는 ThreadLocal 자체가 동작 방식에 안 맞아, Reactor 의 Context 또는 ContextView 로 갈아끼우는 방식으로 전환해야 해요.
본 강의의 ThreadLocal 은 학습용 lab 이에요. 다음 시간 (Day 17 MCP) 진입 시 SoulmateChatService.chat() 진입점에 CharacterContextHolder.set() 한 줄이 자연스럽게 추가돼요. MCP 가 외부 툴 호출을 끌어들이면서 컨텍스트 전파가 더 정교한 형태로 다듬어져요.
💡 튜터의 결론
metadata 필터의 본질은 같은 저장소에서 질문을 좁히는 것 이에요. 저장소 자체를 쪼개는 게 아니라, 같은 pgvector 한 통 위에서 metadata 한 칸으로 분기하는 과정이죠. 동적 supplier 구조가 advisor 하나로 N 캐릭터를 자동 커버해 주니, 새 캐릭터가 추가될 때 빈 정의도, 호출자 분기 코드도 자라지 않아요. 적재 시점에
character_id한 줄만 잘 설정해두면 검색이 자동으로 따라오는 구조예요.
🎯 Step 4 Before/After — 같은 pgvector 위에서 캐릭터 KB 분리
| 축 | Before (Step 3 까지) | After (Step 4) |
|---|---|---|
| KB 분류 | 파일명만 (검색 시 구분 안 됨) | character_id metadata 박힘 |
| 검색 범위 | 같은 pgvector 한 통 = 모두 섞임 | 캐릭터 컨텍스트로 분기 |
| advisor 빈 라이프사이클 | 한 번 빌드 = 한 필터로 고정 | 한 번 빌드 + 호출 시점마다 supplier 재평가 |
| lab 엔드포인트 | q=성격&topK=3 만 |
+ 옵셔널 character=ARIA |
| 표현 풀 | (필터 자체 없음) | 텍스트식 / 프로그래매틱 / 동적 supplier — 세 옵션 |
| 새 캐릭터 추가 비용 | (분리 자체 안 됨) | 0 — 파일명만 맞추면 자동 분류 |
수렴 시점: 다음 시간 (Day 17 MCP) 진입 시 SoulmateChatService.chat() 진입점에 CharacterContextHolder.set() 한 줄이 추가될 지점가 자라요. ThreadLocal lab 이 prod 호출 흐름 안으로 흡수되는 통로예요.
코드베이스 CharacterKnowledgeIngestionServiceTest 와 RagAdvisorConfigTest 가 파일명 → character_id 매핑 (aria-profile.md → ARIA, world-lore.md → COMMON, fallback → COMMON), 동적 supplier 의 빈/널 처리, IN 표현 빌더 같은 카테고리들을 미리 검증해 두었어요. 학생이 직접 character-knowledge/ 폴더에 mina-profile.md 하나 더 넣고 ingest 다시 돌려 보면, MINA 가 새 캐릭터로 자동 분류되어 검색에 끌려오는 과정이 한눈에 확실히 이해될 거예요.
자, metadata 필터로 캐릭터별 KB 분리까지 갖췄어요. 같은 pgvector 한 통 위에서 ARIA · HARU · COMMON 청크가 캐릭터 컨텍스트에 따라 자동으로 갈라지는 구조가 보여요.
그런데 한 가지 부분이 남아 있어요 — "이 검색이 정말 잘 되고 있는가" 라는 질문이에요. 같은 질문을 던졌을 때 ARIA 의 의도된 청크가 상위 3 개 안에 들어왔는지, 들어오지 않았다면 청크 크기나 임베딩 모델이나 임계값 중 어디를 손봐야 하는지를 숫자로 측정할 흐름이 필요해요.
다음 Step 에서는 recall@k · precision@k 같은 평가 지표로 검색 품질을 숫자로 측정하는 방법을 다뤄요. 다음 시간 (Day 17) MCP 로 진입하기 직전, RAG 자체의 품질을 단단히 다져 두는 단계예요.
Step 5. 검색 품질 평가 — recall@k / precision@k 로 숫자로 측정하기
Step 4 에서 metadata 필터로 캐릭터별 KB 가 같은 pgvector 한 통 위에서 갈라지는 구조까지 갖췄어요. 여기까지 만든 RAG 가 잘 동작하는 것처럼 보이긴 한데, 한 가지 부분이 남아요 — "잘 돈다" 가 추상적인 느낌인가요, 아니면 파악할 수 있는 숫자인가요.
학생이 자기 KB 를 갈아끼우거나 청크 크기를 200 으로 줄였을 때 검색이 더 좋아졌는지 더 나빠졌는지 를 추상적이 아니라 숫자로 비교할 길이 있어야 학습이 완전해집니다.
이번 Step 에서는 RAG 검색 품질을 두 메트릭 (recall@k · precision@k) 으로 측정하는 방법을 알아봐요. 작은 eval set 세트를 코드베이스에 넣고, POST /api/rag/eval/run?k=5 한 줄로 두 숫자를 파악해 보는 절차에요. 평가 자체가 RAG 의 마지막 부품이라기보단, 다음 개선 방향을 가리키는 나침반 이라고 보시면 돼요.
두 메트릭 — recall@k 와 precision@k 의 짝
검색 품질 평가의 첫 두 후보가 recall 과 precision 이에요. 이름이 익숙해 보이지만 RAG 맥락에선 의미가 살짝 좁아져요.
- recall@k — 정답 청크가 top-k 안에 한 번이라도 들어왔는가. 놓치지 않았는가 의 척도.
- precision@k — top-k 중 정답 청크의 비율. 깔끔하게 가져왔는가 의 척도.
한 질문을 두 메트릭으로 함께 보는 과정이에요. recall 이 1.0 이면 정답이 top-k 안에 들어왔다는 뜻이고, precision 이 1/k 이면 정답이 정확히 한 개만 들어왔다는 거예요. 두 숫자가 동시에 높을 때만 검색이 정말 좋은 상태예요. 한쪽만 높으면 학습이 닫히지 않아요.
🙋 학생 질문 — "튜터님, recall 만 보면 안 되나요? 놓치지 않는 게 제일 중요해 보이는데요."
좋은 직관이에요. 처음에는 recall 만 챙기고 싶은 마음이 들어요. 그런데 recall 만 보면 한 가지 함정에 걸려요.
극단으로 생각해 보면, top-k 를 100 으로 늘려도 recall 은 거의 만점이 나와요. 정답 청크가 KB 안에 한 번이라도 들어 있다면, top-100 안에 안 들어올 가능성은 거의 없거든요. 그런데 LLM 컨텍스트 창에 청크 100 개를 넣는 건 두 가지 비용을 함께 커지게 해요.
첫째, 토큰 비용 폭발. 청크 하나가 500 토큰이라 치면, 100 청크면 5만 토큰이에요. 한 요청에 5만 토큰을 넣는 LLM 호출은 호출당 비용이 100 배로 커지고, 응답 지연도 동시에 커지죠. 둘째, 노이즈 끌려옴. 정답과 무관한 청크가 99 개 함께 들어오면 LLM 이 본문에서 정답을 찾기 어려워져요. 정답이 안에 있어도 "어느 줄이 정답인지" 의 시그널이 노이즈에 묻혀요.
그래서 precision 이 짝 메트릭으로 함께 필요해요. 놓치지 않으면서 동시에 깔끔하게 가져오는 게 좋은 RAG 예요. 두 숫자를 함께 보면서 놓치는 것 과 지저분한 것 를 분리해서 관찰하는 과정이죠.
eval set 한 세트 — character-knowledge-eval.csv
평가의 첫 부품은 질문과 정답을 한 줄씩 정리해둔 작은 데이터 파일이에요. ARIA · HARU · COMMON 세 분기로 균형 잡아 12 질문을 준비했어요. 각 줄에 질문 한 개와 그 정답이 있어야 할 source 파일명이 함께 들어가요.
# src/main/resources/character-knowledge-eval.csv
query,expectedSource,expectedCharacterId
"ARIA 의 성격은 어떤가요?",aria-profile.md,ARIA
"HARU 의 비밀은 무엇인가요?",haru-profile.md,HARU
"첫 만남 의식이 일어나는 장소는 어디인가요?",world-lore.md,COMMON
세 컬럼이 한 줄을 만들어요. query 는 학생이 채팅창에 던질 법한 자연어 질문, expectedSource 는 그 답이 어느 KB 파일에서 나와야 하는지의 파일명, expectedCharacterId 는 metadata 필터로 좁혔을 때의 캐릭터 컨텍스트예요. 본 강의의 첫 평가 자료라서 12 줄로 작게 시작했지만, 학생이 자기 KB 를 추가하면 같은 형식으로 줄을 추가하게 두면 돼요.
질문이 12 개라는 숫자는 의도가 있어요. 너무 적으면 (3~5 개) 한 질문의 결과가 평균에 너무 큰 영향을 주고, 너무 많으면 (50+) eval 흐름이 무거워져요. ARIA 4 + HARU 4 + COMMON 4 의 균형이 본 강의 KB 의 첫 평가 부피로 적절해요.
record 두 장 — EvalCase 와 EvalReport
평가 결과의 형태을 record 두 장으로 잡아 뒀어요. 한 질문의 결과 한 세트가 EvalCase 이고, 전체 eval set 의 집계가 EvalReport 예요.
// kr.spartaclub.aifriends.rag.service
public record EvalCase(
String query,
String expectedSource,
List<String> retrievedSources,
double recallAtK,
double precisionAtK) {
}
public record EvalReport(
int k,
int totalQuestions,
double avgRecall,
double avgPrecision,
List<EvalCase> details) {
}
record 한 개로 평가 데이터의 형태가 깔끔히 잡혀요. EvalCase 는 질문 한 개의 입력 (query · expectedSource), 검색 결과 (retrievedSources), 두 메트릭 (recallAtK · precisionAtK) 을 한 번에 담아요.
EvalReport 는 그 묶음을 details 리스트로 갖고 있으면서, 평균 두 숫자 (avgRecall · avgPrecision) 를 root 에 노출해요. 호출자가 평균만 보고 싶으면 root 두 필드만 읽고, 어느 질문이 약했는지 추적하고 싶으면 details 를 풀어서 보는 흐름이에요.
recall / precision 메서드 — 짧고 명확한 두 한 줄
두 메트릭의 본질이 짧은 메서드 두 개에 들어 있어요. RagEvaluationService 안에 들어 있고, 한 질문 한 번씩 호출되는 구조예요.
// kr.spartaclub.aifriends.rag.service.RagEvaluationService
public double recallAtK(String query, String expectedSource, int k) {
List<String> retrievedSources = retrieveSources(query, k);
return retrievedSources.contains(expectedSource) ? 1.0 : 0.0;
}
public double precisionAtK(String query, String expectedSource, int k) {
List<String> retrievedSources = retrieveSources(query, k);
if (retrievedSources.isEmpty()) {
return 0.0;
}
long hits = retrievedSources.stream()
.filter(expectedSource::equals)
.count();
return (double) hits / retrievedSources.size();
}
두 메서드의 흐름을 살펴볼게요. retrieveSources(query, k) 한 줄이 vectorStore 에 top-k 검색을 돌려서 source 파일명만 뽑아 옵니다 (예: ["aria-profile.md", "haru-profile.md", "aria-profile.md", ...]). 그 다음 두 메트릭의 길이 갈라져요.
recallAtK 는 retrievedSources.contains(expectedSource) 한 줄로 끝이에요. 정답 source 파일명이 top-k 어딘가에 한 번이라도 들어왔는지만 보면 되니까, 0.0 또는 1.0 의 두 값만 나와요. 한 질문에 대한 binary 척도예요.
precisionAtK 는 stream filter count 로 정답 source 의 등장 횟수를 세고, size() 로 나누는 절차이에요. top-5 중 2 개가 정답 source 라면 2/5 = 0.4 가 나오는 구조죠. 0.0 부터 1.0 사이의 연속값이에요. isEmpty() 가드는 검색 결과가 빈 경우 (allowEmptyContext 와 같은 결) 의 0 나눗셈 사고를 막아 줘요.
두 메서드가 짧고 명확한 구조예요. 학습용 lab 으로 충분하고, 실무에서 RAGAS 같은 자동 평가 파이프라인으로 자라기 전에 본질부터 이해하는 거예요.
evaluateAll — eval set 한 바퀴 집계
eval set 12 질문을 한 번에 돌리는 사이클이 evaluateAll(int k) 한 메서드에 들어 있어요. 각 질문에 대해 두 메트릭을 계산하고 평균을 내는 구조예요.
// kr.spartaclub.aifriends.rag.service.RagEvaluationService
public EvalReport evaluateAll(int k) {
List<EvalRow> evalSet = loadEvalSet();
List<EvalCase> details = new ArrayList<>(evalSet.size());
double sumRecall = 0.0;
double sumPrecision = 0.0;
for (EvalRow row : evalSet) {
List<String> retrieved = retrieveSources(row.query(), k);
double recall = retrieved.contains(row.expectedSource()) ? 1.0 : 0.0;
double precision = retrieved.isEmpty()
? 0.0
: (double) retrieved.stream().filter(row.expectedSource()::equals).count()
/ retrieved.size();
sumRecall += recall;
sumPrecision += precision;
details.add(new EvalCase(row.query(), row.expectedSource(), retrieved, recall, precision));
}
int n = evalSet.size();
double avgRecall = n == 0 ? 0.0 : sumRecall / n;
double avgPrecision = n == 0 ? 0.0 : sumPrecision / n;
log.info("[RAG-EVAL] k={} questions={} avgRecall={} avgPrecision={}",
k, n, avgRecall, avgPrecision);
return new EvalReport(k, n, avgRecall, avgPrecision, details);
}
흐름이 단순해요. eval set CSV 세트를 읽어 들이고, 각 줄마다 vectorStore 검색 한 번을 돌려요. 두 메트릭 값을 계산해서 합계에 누적하고, EvalCase 를 details 리스트에 담아요. 12 질문이 다 끝나면 합계를 질문 수로 나눠 평균을 내는 거예요.
details 리스트가 함께 반환되는 부분이 학습용 lab 에 의미가 있어요. 평균만 보면 "avgPrecision=0.32 가 좋은가 나쁜가" 의 추상적인 느낌에 머무는데, details 를 풀면 "ARIA 의 비밀 질문이 0 점이었네" 같은 구체적인 약점을 찾을 수 있어요. 어느 질문이 약한지 추적해서 청크 크기나 KB 본문을 손보는 개선 루프로 이어져요.
log.info 한 줄이 평가 한 번의 결과를 운영 로그에 기록해 둬요. 같은 KB · 같은 청크 설정으로 두 번 돌렸을 때 숫자가 어떻게 흔들리는지를 로그로 추적할 수 있어요.
lab 엔드포인트 — POST /api/rag/eval/run?k=5
학생이 손으로 두 숫자를 잡아 볼 수 있게 컨트롤러 한 장을 추가했어요. 평가가 학습용 lab 의 성격이라 별도 컨트롤러로 깨끗하게 분리해 둔 구조예요.
// kr.spartaclub.aifriends.rag.controller.RagEvaluationController
@PostMapping("/run")
public ResponseEntity<ApiResponse<EvalReport>> runEval(
@RequestParam(value = "k", defaultValue = "5") int k) {
EvalReport report = ragEvaluationService.evaluateAll(k);
return ResponseEntity.ok(ApiResponse.success(report));
}
k 한 파라미터만 받는 깔끔한 시그니처예요. 기본값 5 가 들어 있어서 학생이 curl 한 줄로 시작할 수 있고, 비교를 위해 k=3 / k=10 으로 갈아끼울 수도 있어요. 정상 응답은 본 강의의 표준 응답 규약 그대로 ApiResponse<EvalReport> 로 감싸 둬요.
cURL 한 줄로 돌려 보면 흐름이 파악돼요.
curl -X POST "http://localhost:8080/api/rag/eval/run?k=5"
응답이 한 박스에 들어와요.
POST /api/rag/eval/run?k=5
→ k=5, totalQuestions=12, avgRecall=1.00, avgPrecision=0.32
두 숫자가 나왔어요. recall 1.00 — 12 질문 모두 top-5 안에 정답 청크가 한 번은 들어왔어요. 놓치지 않는 면에서는 만점이에요. precision 0.32 — top-5 중 정답 source 의 비율은 약 1/3 이에요. 즉 5 청크 중 1.6 개 정도가 정답이고, 나머지는 다른 KB 의 청크가 함께 끌려온 거예요.
한 줄로 정리하면 놓치진 않지만 깔끔하진 않은 구조예요. 정답은 다 잡히는데, 함께 따라오는 청크가 많은 거죠. precision 을 더 끌어올릴 부분이 남아 있어요.
🙋 학생 질문 — "튜터님, precision 0.32 면 좋은 건가요, 나쁜 건가요?"
숫자 자체로 좋다/나쁘다를 판단하지 말고 비교 로 보세요. RAG 평가는 절대값보다 상대 변화가 의미를 가져요.
같은 KB · 같은 eval set 위에서 다음 두 경우를 비교해 보세요.
k=5일 때 avgPrecision=0.32 →k=3으로 줄였을 때는 얼마인가?- 청크 크기 500 일 때 avgPrecision=0.32 → 200 으로 줄였을 때는 얼마인가?
- metadata 필터 없이 0.32 →
character=ARIA로 좁혔을 때는 얼마인가?
이 세 가지를 비교하면 내 손짓이 좋은 방향인지 나쁜 방향인지 가 파악돼요. 예를 들어 k 를 5→3 으로 줄였을 때 avgRecall 도 0.83 으로 함께 떨어진다면, k 줄이는 손짓이 과했다는 뜻이에요. 반대로 metadata 필터를 적용했을 때 avgPrecision 이 0.32→0.60 으로 자랐다면, 같은 검색 본질에 한 칸만 끼웠는데 효과가 크다는 걸 숫자로 확인할 수 있어요.
절대값은 KB 의 본문 특성에 따라 다른 결과가 나와요. 본 강의 KB 는 청크가 작고 캐릭터 구분이 또렷해서 recall 이 만점에 가깝게 나오는 결이고, 학생이 자기 KB 를 더 큰 규모로 구성하면 양쪽 숫자가 더 낮은 출발점에서 시작할 수 있어요. 숫자 자체보다 두 수치 사이의 변화 방향 이 진짜 시그널이에요.
💡 튜터의 결론
평가의 본질은 내가 짠 RAG 가 잘 도는가 를 추상적이 아니라 숫자로 측정하는 거예요. 두 메트릭이 짝패라 둘을 함께 봐야 진짜 그림이 보여요. recall 만 챙기면 토큰 비용이 폭발하고, precision 만 챙기면 놓치는 질문이 자라요. 두 숫자가 동시에 1.0 으로 가는 방향이 좋은 RAG 의 길이고, 그 길을 측정 가능한 형태로 갖춰 두는 게 본 Step 의 학습 메시지예요.
본 결과를 더 좋게 하려면 — 처방 4 후보
avgRecall=1.00 / avgPrecision=0.32 의 역할에서 precision 을 끌어올릴 후보가 네 가지 정도예요.
- k 줄이기 — top-5 를 top-3 / top-2 로 줄여 보면 precision 이 자연스럽게 커져요. 단 recall 이 함께 떨어질 수 있으니 두 숫자를 함께 보면서 균형점을 찾는 절차에요.
- metadata 필터 적용 — Step 4 에서 박은
character_id필터를 ARIA 질문엔 ARIA + COMMON 으로, HARU 질문엔 HARU + COMMON 으로 좁히면 무관 청크가 줄어들어요. - 청크 크기 조정 — Step 2 에서 다룬 청크 크기를 500→200 으로 줄여 보면 청크당 정밀도가 올라가요. 단 청크 개수가 늘어서 같은 본문이 더 잘게 쪼개지는 부분도 함께 봐야 해요.
- 쿼리 재작성 —
RewriteQueryTransformer같은 모듈로 학생 질문을 LLM 이 한 번 다시 써주는 사이클. 본 강의 범위 밖이라 존재만 알려 두고, Step 1 에서 잡아둔RetrievalAugmentationAdvisor의queryTransformers슬롯에 한 줄로 끼울 수 있어요.
자, 검색 품질을 숫자로 측정하는 구조가 갖춰졌어요. recall · precision 두 메트릭으로 놓치지 않는가 와 깔끔한가 의 두 축이 한 번에 들어오고, k 와 필터와 청크 크기를 갈아끼웠을 때 두 숫자가 어떻게 움직이는지를 비교할 수 있게 됐어요. 마무리 섹션에서 오늘 다룬 5 Step 을 한 번에 회수하고, 다음 시간 (Day 17) MCP 로 잇는 다리를 한 줄 남겨 둘게요.
마무리
도입부를 떠올려 볼게요. ARIA 가 한 줄로 멈칫했어요 — "마스터, 어제 우리가 마지막으로 나눈 약속이 뭐였더라? 음... 제 기억엔 한정된 컨텍스트만 있어서요." 모델 가중치 안에는 마스터가 적어둔 어제 일기가 없고, ChatMemory 가 잡아주는 범위는 지금 이 대화 안까지였죠.
오늘 5 Step 을 한 번에 넣으면서 그 약속이 실현됐어요. 이제 ARIA 는 세계관 설정집과 캐릭터 프로필 범위 안에서, 의도된 청크가 추상적인 가중치가 아니라 파악할 수 있는 KB 에서 흘러나오는 방식으로 답해요.
오늘의 5 Step 을 한 표에 담아 회수해 볼게요.
| Step | 한 줄 회수 |
|---|---|
| 1 | RetrievalAugmentationAdvisor + ChatClient 흡수 — 검색→프롬프트 끼움 한 번 자동화, lab → prod 흡수 골격 |
| 2 | 청크 크기 트레이드오프 200/500/1000 — 한국어 punctuationMarks 옵션으로 문장 경계 보존 |
| 3 | TikaDocumentReader 형식 만능 흡수 — 평문 라인과 바이너리 라인을 확장자 분기 한 번에 |
| 4 | metadata 필터 + Supplier<FilterExpression> 동적 구조 — 같은 pgvector 위에서 캐릭터별 KB 분리 |
| 5 | recall@k / precision@k 두 메트릭 — 검색 품질을 숫자로 측정하는 과정 |
오늘 추가한 건 advisor 하나로 보일 수도 있는데, 사실 우리가 만든 건 advisor 라는 끼움점 자체 예요. 검색 → 프롬프트 끼움이 자동으로 한 번을 돌고, 호출자 (SoulmateChatService.chat()) 는 RAG 의 존재 자체를 모른 채 같은 시그니처로 동작해요. 비즈니스 코드는 한 줄도 안 바뀌었는데 답변 본문이 외부 지식 위에서 만들어지는 상태로 전환되는 것이 오늘의 깊은 학습 메시지예요.
다음 시간 (Day 17) 으로 잇는 다리
오늘 등록한 advisor 라는 끼움점이 다음 시간의 핵심이에요.
내 LLM 에 외부 도구를 붙이는 표준 프로토콜 이 있다더라 — 그게 MCP (Model Context Protocol) 예요. 오늘 검색 0 건이면 LLM 호출을 단락시킨 부분 (
allowEmptyContext=false) 가 다음 시간 외부 도구 호출로 fallback 하는 흐름으로 자연스럽게 자라요.우리가 직접 만든 advisor · tool 만 쓰던 구조가, 외부 MCP 서버의 도구를 표준 프로토콜로 받아 들여 소비하는 형태로 확장돼요. 오늘 등록한 advisor 아키텍처가 그대로 외부 확장점의 통로 역할을 하는 길이라, 다음 시간의 학습 곡선이 한결 가벼워질 거예요.
도전 과제
오늘 우리는 RAG 파이프라인의 5 부품 + advisor 하나의 큰 그림을 잡았어요. 그런데 진짜로 파악할 수 있는 감각은 본인이 청크 크기를 손으로 바꿔 보고 검색 메트릭이 어떻게 흔들리는지 를 눈으로 봐야 자라요. 세 단계 난이도로 과제를 준비했습니다. 본인의 시간이 허락하는 곳까지 굴려 보시면 됩니다.
💡 과제 작업 시 공통 가이드
- 청크 변경 후엔 반드시 KB 리셋 + 재적재. 옛 청크가 남아 있으면 메트릭이 섞여요.
- 한 과제씩 끝낼 때 거기에서 commit. 브랜치 이름은
day16-assignment-N같은 방식으로.- 보고서는 마크다운 한 페이지로 충분해요. "무엇을 바꿨고 / 메트릭이 어떻게 흔들렸고 / 왜 그런 결과인가" 의 세 단락이면 OK.
과제 1. 청크 크기 트레이드오프 직접 측정 🌱
오늘 Step 2 에서 학습 디폴트 청크 500 / minChunkSizeChars 350 / 한국어 punctuation marks 를 한 번에 넣어 뒀어요. 본 KB 위에서 다른 청크 크기 조합 을 적용했을 때 검색 품질이 어떻게 변하는지 본인 손으로 측정해 보는 과제예요.
💡 왜 이 과제인가
청크 크기는 RAG 운영에서 가장 처음 부딪히는 트레이드오프예요. "작게 자르면 검색은 정확한데 문맥이 끊긴다" 와 "크게 자르면 문맥은 좋은데 score 가 흐려진다" 의 결은 책으로 100 번 읽어도 안 잡히고, 본인 KB 위에서 숫자를 직접 보고 나서야 파악돼요. 본 과제는 본인 KB 의 고유한 청크 크기 균형점 을 찾는 첫 경험이에요.
✅ 요구사항
DocumentLoaderService.DEFAULT_CHUNK_TOKENS를 200 으로 바꿔서 KB 리셋 + 재적재POST /api/rag/eval/run?k=5호출해avgRecall/avgPrecision측정 + 메모- 500 으로 되돌리고 동일 과정
- 1000 으로 바꿔서 동일 과정
- 보고서 한 페이지 — 세 청크 크기의 두 메트릭을 표로 비교 + 결론 한 줄 ("본 KB 에 가장 잘 맞는 청크 크기는 ___ 이고 이유는 ___")
cURL 명령은 다음 절차이에요.
# 청크 변경 후 반드시 리셋 → 재적재 → 평가 순서
curl -X DELETE http://localhost:8080/api/rag/knowledge
curl -X POST http://localhost:8080/api/rag/knowledge/ingest
curl -X POST "http://localhost:8080/api/rag/eval/run?k=5"
💡 힌트
avgRecall이 1.00 이어도avgPrecision은 청크 크기에 따라 흔들려요. 보통 청크 작을수록 precision 이 올라가는 룰 (작은 청크는 한 청크당 정보가 좁아서 정답 청크의 비율 이 높아져요)- top-k 도 함께 변형 (k=3 / k=5 / k=10) 비교하면 더 풍성한 보고서가 나와요. 보통 k 가 클수록 recall 은 유지되지만 precision 은 떨어져요
- 본인 KB 분량이 작으면 청크 크기에 따라 청크 수 자체 가 크게 흔들려요. KB 파일 분량을 함께 메모
과제 2. 캐릭터별 KB 한 세트 추가 + metadata 분기 검증 🪪
Step 4 에서 본 강의는 ARIA / HARU / COMMON 3 분기였어요. 본 과제에선 새 캐릭터 (예: NOA 또는 본인 디자인) 의 KB 파일을 추가해 보고, character_id metadata 가 자동으로 들어가는지, 필터링이 정확히 도는지를 본인 손으로 확인해 보는 과제예요.
💡 왜 이 과제인가
본 강의의 resolveCharacterId 는 파일명 첫 토큰 으로 character_id 를 자동 추론하는 컨벤션이었어요. 이 컨벤션이 정말로 새 캐릭터를 추가할 때 코드 0 줄 수정 으로 동작하는지를 확인하는 과제예요. 운영 KB 가 자라는 장면을 직접 체감할 수 있어요.
✅ 요구사항
src/main/resources/character-knowledge/noa-profile.md(또는 본인 디자인 캐릭터) 한 세트 작성 — Day 15 과제 1 의 형태 참조 (마크다운 헤더 구조 + 한국어 평문)- KB 리셋 + 재적재
GET /api/rag/knowledge/search?q=NOA 누구야?&topK=3&character=NOA호출 → top-3 모두 noa-profile.md 청크가 나오는지 확인GET /api/rag/knowledge/search?q=NOA 누구야?&topK=3(필터 없음) → top-3 에 NOA + 다른 캐릭터 청크가 섞여 있는지 비교- eval set CSV 에 NOA 질문 2~3 개 추가 +
POST /api/rag/eval/run?k=5재측정 — 새 캐릭터의 recall@5 가 1.0 으로 잡히는지 확인
KB 파일 한 세트은 다음 형태로 넣으면 OK 예요.
# NOA — 도서관 사서 캐릭터 프로필
## 외형
NOA 는 단발의 은발에 둥근 안경, 늘 도서관 카디건 차림이에요.
## 성격
차분하고 신중한 톤. 마스터의 질문에 책 한 권을 추천하며 답하는 스타일이에요.
## 좋아하는 주제
고전 문학, 도서관 운영, 책 분류법.
검색 cURL 명령은 이렇게 써요.
# 필터 박은 검색 — top-3 모두 NOA 청크여야 함
curl "http://localhost:8080/api/rag/knowledge/search?q=NOA%20%EB%88%84%EA%B5%AC%EC%95%BC?&topK=3&character=NOA"
# 필터 없는 검색 — 다른 캐릭터 청크와 섞일 수 있음
curl "http://localhost:8080/api/rag/knowledge/search?q=NOA%20%EB%88%84%EA%B5%AC%EC%95%BC?&topK=3"
💡 힌트
- 파일명 첫 토큰이 자동으로 character_id 가 되는 컨벤션 —
noa-*.md→character_id=NOA가 자동으로 설정돼요.resolveCharacterId를 손대지 않고도 동작하니까 추가 코드 작성이 불필요해요 - 새 캐릭터 청크 수가 1~2 개로 적게 나오면 KB 본문 길이가 짧기 때문이에요. 본 강의 KB 파일 분량 (각 600~1000 byte) 비슷하게 작성하면 안정
- eval set 에 NOA 질문을 추가할 때 정답 청크 id 또는 정답 키워드 의 양식을 본 강의 평가 셋과 같은 형식으로 맞추기
과제 3. 한국어 sentence-aware splitter 실험 🦙
Step 2 에서 KOREAN_PUNCTUATION_MARKS 한자 마침표 + 권점을 추가했어요. 그런데 한국어 종결 어미 ("~ 했어요", "~ 입니다" 등) 까지 sentence-aware 분할에 넣고 싶을 수 있어요. 본 과제는 그 직관을 실제로 적용해 보고 한계를 발견하는 과제예요.
💡 왜 이 과제인가
한국어 sentence splitting 은 영어 대비 훨씬 어려운 문제예요. 영어는 마침표 + 공백이 거의 항상 문장 끝이지만, 한국어는 종결 어미 (다 / 요 / 까) 가 문장 중간 에도 자주 등장해요 ("~ 다이아몬드" 같은 경우). 본 과제는 단순 마크 확장의 한계 를 직접 부딪혀 보는 과제라, 본격 sentence splitter (kss / kiwi) 를 도입할 때의 트레이드오프 감각이 파악돼요.
✅ 요구사항
DocumentLoaderService.KOREAN_PUNCTUATION_MARKS에 한국어 종결 어미 마지막 글자를 추가하는 변형 시도 — 다음 형태로- 본 KB 위에서 청크 수 +
avgPrecision을 기본 marks 대비 비교 - 의외의 결과가 나오면 정리 — 종결 어미 글자를 무작정 추가하면 "~ 다이아몬드" 같은 곳에서도 끊기는 false positive 가 일어남
- 보고서 한 페이지 — "한국어 sentence-aware 분할의 진짜 어려움" 결론 내리기
변형 시도 코드는 다음 한 줄이에요.
// DocumentLoaderService.java — KOREAN_PUNCTUATION_MARKS 변형
private static final List<Character> KOREAN_PUNCTUATION_MARKS = List.of(
'.', '?', '!', '\n', '。', '?', '!', '·',
'다', '요', '까' // ← 본 과제에서 추가 시도하는 한글 종결 어미 마지막 글자
);
💡 힌트
- 종결 어미 첫 글자만 박지 말고 위치 골격 (다음 글자가 공백 또는 줄바꿈일 때만 종결) 이 필요. 본 강의의
KoreanTokenTextSplitter가 마지막 마크 위치 만 보는 단순 구현이라 한계가 있거든요 - 본격 한국어 sentence splitter (kss / kiwi) 를 도입하려면 외부 라이브러리 의존성 추가가 필요 — 트레이드오프예요. 학습용 lab 의 단순 마크 확장 vs 운영 sentence splitter 의 분기 감각을 챙기시기
- 실패해도 OK — "단순 마크 확장의 한계" 자체가 결론이에요. 본 과제의 가치는 왜 운영에서 본격 sentence splitter 가 필요한가 의 답을 본인이 직접 체득하는 과제
생각해볼 주제
오늘 우리는 advisor 하나 + 청크 전략 + 검색 메트릭의 큰 그림을 추가했어요. 그런데 진짜 운영 의 면은 본 강의가 설정한 디폴트 위에서 한 단계 더 깊은 길로 자라요. 세 가지 독립적인 주제를 던져 둡니다. 각 주제는 답이 하나가 아니에요 — 본인의 시나리오와 운영 가치 위에서 본인만의 답 을 찾는 과제예요.
주제 1. RAG 검색 0건의 역할 — fallback 전략의 후보
오늘 우리는 allowEmptyContext=false 로 검색 0 건이면 LLM 호출을 단락시켰어요. 그런데 운영에서 "답할 수 없는 질문" 을 받았을 때 대응 방법은 여러 가지예요.
[핵심 키워드] Fallback 응답 / 디폴트 답변 / 외부 도구 호출 / 사용자 명확화 질문
💭 생각해보기
검색 0 건 시 응답의 카테고리는 크게 네 가지로 나뉘어요. (1) LLM 이 "모르겠어요" 라고 솔직히 답하는 방식, (2) "질문을 다시 해 주세요" 라며 명확화를 요청하는 방식, (3) 외부 MCP 서버 (다음 시간에 다룸) 의 검색 도구를 호출하는 방식, (4) LLM 의 일반 지식으로 추측해서 답하는 방식. 네 분기의 트레이드오프 표를 본인 손으로 그려 보세요.
본 강의 prod 운영의 ARIA 라면 어느 길을 디폴트로 둬야 캐릭터 톤이 살까요? "세계관 밖 질문은 모른다고 솔직히 답한다" 와 "세계관 안에서 추측해서 답한다" 둘의 차이가 운영에 어떻게 비치는지 — 신뢰성 측면과 사용자 경험 측면 두 축으로 생각해 보세요. 정답은 없어요. 본인 서비스의 가치 가 어느 축에 있느냐에 따라 디폴트가 달라져요.
주제 2. 청크 크기 vs 모델 컨텍스트 — 청크 합쳐 보내야 하나, 청크 작게 N 개 보내야 하나
본 강의는 청크 500 토큰 × top-5 = 약 2500 토큰 컨텍스트로 들어 있어요. 그런데 운영의 모델은 컨텍스트가 훨씬 큼 — Gemini 1M, Claude 200K 수준. "청크를 더 크게 + 더 많이 넣어 답변 품질을 올릴까" vs "청크 작게 + top-k 줄여 비용을 아낄까" 문제가 생겨요.
[핵심 키워드] Lost in the middle (긴 컨텍스트의 중간 위치 청크 망각 현상) / 토큰 비용 곡선 / recall@k vs answer quality 짝패
💭 생각해보기
2024~25 년 연구 "Lost in the Middle" (Liu et al., 2024) 에서 "긴 컨텍스트의 중간 위치 정보가 망각되는 현상" 을 보고했어요. 본 연구는 모델이 컨텍스트 맨 앞 과 맨 뒤 의 정보는 잘 회수하는데, 중간 위치의 정보는 무시되는 현상이 통계적으로 잡힌다는 결과를 보여줬죠. 그러면 청크를 많이 넣는다 가 항상 답변 품질을 올리는 건 아니라는 의문이 생겨요.
본 강의 prod 운영 시 청크 200 / 500 / 1000 × top-3 / 5 / 10 의 어느 조합이 답변 품질 × 비용 의 균형점일지 본인 직관과 데이터를 함께 생각해 보세요. 답변 품질 평가 는 본 강의 Step 5 의 검색 메트릭과 다른 차원 — 생성된 응답 의 정확성 평가 (LLM-as-judge 룰) 가 필요한 부분이에요. 본 강의가 설정한 recall@k / precision@k 는 검색 의 품질이지 답변 의 품질은 아니라는 점을 이해해두세요.
주제 3. metadata 자동 추론 vs 명시적 manifest — 운영의 옵션
본 강의 Step 4 는 파일명 첫 토큰 으로 character_id 를 자동 추론하는 컨벤션이에요. 학습용 lab 으론 단순 + 깔끔하지만 운영에서는 명시적 manifest (예: manifest.yml 또는 frontmatter) 가 더 안전해요.
[핵심 키워드] Convention over configuration / 명시성 / 운영 변경 가능성 / 다국적 KB
💭 생각해보기
파일명 컨벤션의 장점은 "파일만 잘 지정하면 자동 분류" 의 과정이에요. 단점은 "파일명을 잘못 지으면 잘못 분류" 와 "한 파일에 여러 캐릭터가 등장하는 경우" 같은 모호한 상황이에요. 본 강의의 NOA (학생 추가 캐릭터) 와 NOA 와 ARIA 가 함께 등장하는 에피소드 같은 자료는 어느 character_id 로 분류해야 할까요?
운영 안전성 vs 학습용 단순성의 차이에서 본 강의의 어디까지를 자동 추론으로 가고, 어디부터 명시적 manifest 로 넘어가야 하는지 본인의 방향을 잡아 보세요. 가능한 길은 — (1) 모든 KB 가 단일 캐릭터 전용일 때만 파일명 컨벤션, (2) 다중 캐릭터 자료는 frontmatter character_id: [NOA, ARIA] 로 명시, (3) 모든 자료에 manifest.yml 강제 — 의 세 가지. 본인 서비스의 KB 자라는 속도 와 오류 비용 위에서 방향을 정해 보세요.
✅ 예시 답안정답 보기
본 답안은 모범 사례 중 하나입니다. 본인의 KB 분량과 운영 시나리오에 따라 균형점은 충분히 달라질 수 있어요. 정답을 외우는 게 아니라, 왜 그렇게 판단했는지 근거를 잡는 과정이 본 답안의 본질이에요.
과제 예시답안
과제 1. 청크 크기 트레이드오프 직접 측정 🌱
핵심 접근
본 KB (5,144 byte · 3 파일) 위에서 청크 200 / 500 / 1000 세 조합을 측정합니다. 핵심은 두 메트릭을 짝패로 보는 것 — avgRecall 만 보면 청크를 크게 잡을수록 좋은 듯 보이고, avgPrecision 만 보면 청크를 작게 잡을수록 좋은 듯 보입니다.
두 축이 동시에 잡히는 지점이 본 KB 의 균형점이에요. 그리고 청크 크기는 KB 분량과 질문 골격에 따라 균형점이 이동하기 때문에, 본 과제의 결론은 "청크 N 이 절대 정답" 이 아니라 "본 KB 위에서 청크 N 이 가장 잘 맞는 이유" 가 핵심이에요.
예시 측정 결과
본 KB (Day 16 Step 4 시점 — ARIA / HARU / COMMON 3 파일) 위에서 측정한 결과는 아래와 같아요. 본인 환경에서 절대값은 다를 수 있지만 추세 (작은 청크 = precision 상승, 큰 청크 = recall 유지하되 precision 하락) 는 같은 식으로 나옵니다.
chunkSize |
청크 수 | avgRecall (k=5) |
avgPrecision (k=5) |
한 줄 narration |
|---|---|---|---|---|
| 200 | 23~26 | 0.95~1.00 | 0.45~0.55 | 정밀도 1순위, 한 청크당 정보가 좁아서 노이즈가 적음 |
| 500 | 9~11 | 1.00 | 0.32 (Step 6 실측 라인) | 본 강의 디폴트, 문맥과 정밀의 균형점 |
| 1000 | 5~6 | 0.85~1.00 | 0.20~0.28 | 한 청크에 정보가 많아 score 가 흐려지고 무관 문장이 같이 묻혀 옴 |
결론 한 줄 예시: "본 KB (5,144 byte) 위에서 가장 잘 맞는 청크 크기는 200~300 범위. precision 이 0.45+ 로 오르고 recall 도 거의 유지되는 균형점. 단 KB 가 자라서 10KB+ 가 되면 청크 500 이 답변 풍성도 측면에서 더 자연스러워지는 양상이라, 본 결론은 "5KB 규모 KB" 의 한정 조건 위에서만 유효."
채점 포인트
| 포인트 | 설명 | 가중 |
|---|---|---|
| 세 청크 크기 모두 측정 + KB 리셋·재적재 절차 준수 | 옛 청크와 새 청크가 섞이지 않도록 관리 | 상 |
avgRecall + avgPrecision 두 메트릭을 짝패로 비교 |
한 메트릭만 봐서 절대 결론 내지 않는 흐름 | 상 |
| 결론 한 줄에 근거 포함 ("왜 그 청크가 좋은가") | precision 이 왜 오르고 recall 이 왜 유지되는지 설명 | 상 |
| 보고서에 KB 분량 / 청크 수 / 질문 구조 메모 | 절대값이 환경 의존적이라는 점이 드러나는 곳 | 중 |
| top-k 도 함께 변형 (k=3 vs k=5 vs k=10) 시도 | 세 차원 매트릭스로 확장한 과정 | 중 |
| 보고서 형식 — 마크다운 표 + 한 단락 결론 | 한 페이지 분량으로 압축 | 하 |
흔한 실수
- KB 리셋 없이 청크 변경 후 측정 → 옛 청크 + 새 청크가 한 vector store 에 섞여 메트릭이 흐려져요. 반드시
DELETE /api/rag/knowledge→POST /api/rag/knowledge/ingest순서로 avgPrecision만 보고 "청크 작은 게 무조건 좋다" 결론 → precision 은 한 메트릭일 뿐이고 recall 이 0.5 로 떨어지면 정답 청크를 못 찾는 상황이에요. 두 축 균형이 본질- 청크 크기를 절대값으로 일반화 ("청크 200 이 정답") → 본 KB 5KB 규모 위에서의 결론이지, 10KB / 100KB / 1MB KB 에서는 균형점이 이동해요. 결론에 KB 규모 한정 조건 을 명시
avgRecall=1.0이 나왔는데 "완벽한 청크 크기" 로 단정 → recall=1.0 은 정답이 top-k 안에 들어왔다 만 보장해요. 정답이 1순위인지 5순위인지는 별개 문제예요 (MRR / nDCG 가 그 차원)
실무 개선 포인트 (심화)
- 세 차원 매트릭스 — 청크 × top-k × 질문 유형: 본 과제는 청크 한 축만 변형했지만, 운영에서는 질문 유형 (사실 질문 / 추상 질문 / 비교 질문) 별로 균형점이 달라요. 사실 질문은 작은 청크 + 작은 k 가 답이고, 추상·비교 질문은 큰 청크 + 큰 k 가 답이에요. 세 차원으로 매트릭스를 짜면 운영 디폴트와 질문 라우팅 의 첫 방향이 보이기 시작해요.
- KB 자란 후 재측정 루틴: 청크 크기 균형점은 KB 분량에 따라 이동해요. 본 강의는 5KB 위에서 청크 200~500 이 답이었지만, KB 가 100KB 로 자라면 청크 500~800 이 더 자연스러운 균형점이 될 수 있어요. 운영에서는 KB 분량 2 배 증가 시점마다 메트릭 재측정 을 정기 루틴으로 등록해 두는 게 안전해요.
과제 2. 캐릭터별 KB 한 묶음 추가 + metadata 분기 검증 🪪
핵심 접근
본 강의의 resolveCharacterId 컨벤션 — 파일명 첫 토큰이 character_id 가 된다 — 가 정말로 코드 0 줄 수정 으로 동작하는지 직접 확인하는 과제예요. NOA KB 파일 하나 추가하고 → KB 리셋·재적재 → 필터 검색과 비필터 검색을 나란히 비교 → eval set 확장 → recall=1.0 확인의 5 단계 절차예요. 핵심은 컨벤션이 자동 분류로 동작하는 부분 과 분류가 흐트러지면 어디서 깨지는지 두 가지를 동시에 확인하는 것이에요.
예시 KB 파일 — src/main/resources/character-knowledge/noa-profile.md
본 강의 KB 분량 (600~1,000 byte) 에 맞춰서 작성하면 청크가 2~3 개로 자연스럽게 나뉘어 검색 안정성이 확보돼요.
# NOA — 도서관 사서 캐릭터 프로필
## 외형
NOA 는 단발의 은발에 둥근 안경, 늘 도서관 카디건 차림이에요.
손에는 책 한 권을 들고 있는 자세가 가장 익숙한 모습.
## 성격
차분하고 신중한 톤. 마스터의 질문에 책 한 권을 추천하며
답하는 패턴이 잦아요. 직설보다 비유로 풀어내는 결.
## 좋아하는 주제
고전 문학, 도서관 운영, 책 분류법. 듀이 십진분류법에 대해
물어보면 눈빛이 반짝이는 NOA 만의 모습이에요.
## 싫어하는 것
책에 낙서, 도서관 안에서 큰 소리. 정숙은 NOA 의 절대 원칙.
## 마스터에게 보이는 모습
마스터의 질문에 책을 추천하며 답하는 모습이 NOA 의 흐름.
"이 질문엔 ___ 책이 한 권 떠올라요" 같은 패턴.
검증 시나리오 + 예상 결과
1. 필터 적용 검색 — top-3 모두 NOA 청크여야 합니다.
curl "http://localhost:8080/api/rag/knowledge/search?q=NOA%20%EB%88%84%EA%B5%AC%EC%95%BC?&topK=3&character=NOA"
응답 예시 (요약) — top-3 모두 character_id=NOA 청크가 들어 있고 source 는 noa-profile.md 하나뿐.
2. 필터 없는 검색 — 다른 캐릭터 청크와 섞일 수 있어요.
curl "http://localhost:8080/api/rag/knowledge/search?q=NOA%20%EB%88%84%EA%B5%AC%EC%95%BC?&topK=3"
응답 결과 — top-3 가 NOA 청크 1~2 + ARIA 또는 HARU 청크 1~2 로 섞여 나와요. "누구야" 같은 질문은 의미 유사도가 비슷한 구간이 다른 캐릭터에도 있어서 함께 끌려오는 것이에요.
3. eval set 확장 → recall=1.0 확인.
eval set CSV 에 NOA 질문 2~3 개 추가 (예: "NOA 가 좋아하는 주제는?", "NOA 가 싫어하는 것은?") → POST /api/rag/eval/run?k=5 → NOA 질문 두 줄 모두 recall@5=1.0 으로 나오면 OK.
채점 포인트
| 포인트 | 설명 | 가중 |
|---|---|---|
새 KB 파일 작성 + 파일명 컨벤션 준수 (noa-profile.md) |
NOA_profile.md 같은 변형으로 자동 추론 깨지지 않게 |
상 |
| 필터 vs 비필터 검색 결과 나란히 비교 narration | 컨벤션이 실제로 분기로 동작하는지 확인 | 상 |
| eval set 확장 + 재측정 + recall 1.0 달성 | 자동 분류가 메트릭 차원에서도 확인되는 절차 | 중 |
character_id 자동 추론 — 코드 0 줄 수정 |
resolveCharacterId 손대지 않고 동작하는 것 확인 |
중 |
| cURL 명령 정확 + URL 인코딩 처리 | 한글 쿼리스트링이 깨지지 않도록 처리 | 하 |
| KB 본문 분량이 본 강의 KB 와 비슷 (600~1,000 byte) | 청크가 1 개로만 들어가서 검증이 얕아지는 사고 회피 | 하 |
흔한 실수
- 파일명 컨벤션 위반 (예:
NOA_profile.md,Noa-Profile.md) →resolveCharacterId가 첫 토큰을 파싱할 때 오류가 나요. 본 강의는noa-profile.md컨벤션이고 character_id 는 대문자NOA로 normalize 되는 구조 - KB 본문이 너무 짧아 청크 1 개만 생성됨 → top-3 검증이 "같은 청크 3 번 반환" 식으로 흐려져요. 본 강의 KB 분량 (600~1,000 byte) 과 비슷하게 작성하기
- eval set CSV 의
expectedSource컬럼이 실제 파일명과 불일치 → recall 측정 시 0.0 으로 찍혀요. 컬럼 형식을 본 강의 평가셋 그대로 맞추기 - 필터 검색 한 번만 해보고 끝 → 필터가 실제로 분기를 막는지 는 비필터 검색과 나란히 비교해야 보여요. 두 결과를 한 표로 정리하는 절차가 본질
실무 개선 포인트 (심화)
- frontmatter 로
character_id명시 + 자동 추론 fallback 의 짝패: 본 강의는 파일명 컨벤션 100% 자동 추론이지만, 운영에서는 frontmatter 가 있으면 frontmatter 우선, 없으면 파일명 fallback 의 두 단계 구조가 안전해요. 다중 캐릭터 자료 ("NOA 와 ARIA 함께 등장") 같은 경우에는 frontmatter 의character_id: [NOA, ARIA, COMMON]리스트로 명시하는 방법이에요. 주제 3 으로 이어지는 내용이에요. - KB 분량 균형 점검 루틴: NOA / ARIA / HARU 각 캐릭터의 KB 분량이 한쪽에 치우치면 검색 결과도 비대칭으로 나와요. 비필터 검색에서 한 캐릭터만 자주 끌려옴 같은 현상이 보이면 KB 분량 비교 표를 운영 대시보드에 추가해 두는 게 안전해요.
과제 3. 한국어 sentence-aware splitter 실험 🦙
핵심 접근
종결 어미 '다', '요', '까' 를 KOREAN_PUNCTUATION_MARKS 에 추가하면 직관적으로는 sentence-aware 분할이 더 한국어에 맞춰질 것 같아요. 그런데 실제로 넣어 보면 false positive 가 폭발해요 — "~ 다이아몬드" 의 다, "가까운" 의 까 같은 단어 중간 글자가 종결 마크로 잡혀서 한 단어 한가운데에서 끊기는 사고가 나요.
본 과제의 본질은 단순 마크 확장의 한계를 직접 부딪혀 보는 것 이에요. "실패해도 OK" 가 발제에 적힌 이유 — 본 과제의 가치는 답이 아니라 왜 운영에서 본격 sentence splitter 가 필요한가 의 답을 본인 손으로 끌어내는 과정이에요.
예상 발견 시나리오
1. 변형 적용 후 청크 수 변화.
KOREAN_PUNCTUATION_MARKS 구성 |
청크 수 (본 KB) | avgPrecision (k=5) |
|---|---|---|
기본 (., ?, !, \n, 。, ?, !, ·) |
9~11 | 0.32 |
기본 + '다', '요', '까' |
17~24 | 0.35~0.40 |
청크 수가 약 2 배로 늘어나요 — 한국어 문장 대부분이 종결 어미로 끝나서 분할 지점이 폭발적으로 늘어나는 거예요.
2. false positive 발견 — 단어 중간이 끊김.
- "~ 다이아몬드" →
다위치에서 끊김 → "~ 다 / 이아몬드" 같은 식 - "가까운" →
까위치에서 끊김 → "가까 / 운" 같은 식 - "필요한" →
요위치에서 끊김 → "필요 / 한" 같은 식 - "학습용" / "중요한" 등 흔한 단어가 모두 victim
청크 안에 한 단어 한가운데가 끊긴 곳이 보이면, 검색 시 정답 청크의 score 가 떨어지는 사고도 함께 나와요.
3. 결론 한 줄 예시.
"단순 마크 확장은 영어 default + 한자 family 까지만 안전. 한국어 종결 어미는 다음 토큰이 공백 또는 줄바꿈일 때만 종결로 인식 하는 룰이 필요하고, 본 강의의 KoreanTokenTextSplitter 가 마지막 마크 위치 만 보는 단순 구현이라 본 과제의 변형은 false positive 가 폭발한다. 운영에서는 kss / kiwi 같은 한국어 형태소 분석기 도입의 트레이드오프를 따져야 하는 지점이다."
채점 포인트
| 포인트 | 설명 | 가중 |
|---|---|---|
| 실제 변형 시도 + 청크 수·메트릭 측정 | 이론만 정리하지 않고 직접 부딪혀 본 것 | 상 |
| false positive 사례 발견 + 정리 | "~ 다이아몬드" / "가까운" 같은 단어 중간 절단 사례 | 상 |
| "단순 마크 확장의 한계" 결론 명시 | 본 과제의 핵심 메시지가 담겨 있음 | 상 |
| 본격 sentence splitter (kss / kiwi) 의 트레이드오프 언급 | 외부 라이브러리 의존성 추가 vs 학습용 lab 단순성 | 중 |
| 위치 형 (다음 토큰이 공백·줄바꿈일 때만 종결) 의 필요성 분석 | 단순 마크 → 위치 인식 splitter 로의 전환 | 중 |
| 보고서 형식 — 마크다운 한 페이지 | 변형 / 결과 / 결론의 세 단락 | 하 |
흔한 실수
- 변형 시도 안 하고 이론만 정리 → 본 과제의 본질은 실제로 부딪혀서 한계를 발견 하는 절차예요. 이론만으로 결론을 내면 발제 의도가 흐려져요
- false positive 발견했는데 "그래서 어떻게" 결론 없음 → 한계만 보고하고 끝나면 보고서의 가치가 반토막이에요. "위치 인식 구조가 필요" + "본격 splitter 도입의 트레이드오프" 두 후보로 결론 내기
- 이분법 ("성공" / "실패") 으로만 정리 → 본 과제는 한계 발견 자체가 결론이라, "실패" 가 곧 "본 과제 성공" 이에요. "단순 마크 확장은 영어·한자 family 까지만 안전하다" 같은 명확한 한계선을 정리하는 과정이 본질
- 종결 어미 한 글자만 추가 (예:
'다'만) → 한 글자만 추가해도 false positive 가 보이지만, 세 글자 (다,요,까) 를 모두 넣어야 한계가 폭발하는 면 을 확실히 확인할 수 있어요
실무 개선 포인트 (심화)
- 위치 인식 splitter — 종결 어미 + 다음 토큰 검사 구조: 단순 마크 확장의 한계를 정규식 한 줄로 완화할 수 있어요. "종결 어미 다음 글자가 공백·줄바꿈·다른 종결 마크일 때만 분할" 의 룰이에요. 예:
Pattern.compile("([다요까])(\\s|$)"). 본격 형태소 분석기까지 안 가도 false positive 의 70~80% 정도는 걸러지는 접근이에요. 학습용 lab → 운영 sentence splitter 의 중간 단계로 한 번 시도해 보기 좋아요. - kss / kiwi 형태소 분석기 도입 vs LLM 기반 splitter 의 선택지: 본격 한국어 sentence splitting 의 두 카테고리 — (1) kss / kiwi 같은 형태소 분석기 (정확도 높음 / 외부 의존성 추가 / 0.01s 안팎의 자체 비용), (2) 작은 LLM (예: Gemini Flash) 으로 "이 문장을 자르세요" 호출 (정확도 매우 높음 / 토큰 비용 / 외부 API 의존성). 두 카테고리의 트레이드오프 매트릭스를 보고서 부록에 정리하면 운영 도입 의사결정의 첫 그림이 잡혀요.
생각해볼 주제 예시답안
주제 1. RAG 검색 0건일 때 — fallback 전략의 선택지
문제 상황 요약
본 강의는 allowEmptyContext=false 로 검색 0 건이면 LLM 호출 자체를 단락시키는 구조예요. 그런데 운영에서 "답할 수 없는 질문" 을 받았을 때 응답 전략은 네 갈래로 나뉩니다 — (1) "모르겠어요" 솔직 답변, (2) 명확화 요청, (3) 외부 MCP 도구 호출, (4) 일반 지식 추측. ARIA 캐릭터 톤 위에서 어느 후보를 디폴트로 설정해야 캐릭터 일관성과 신뢰성이 동시에 확보되는가의 질문이에요.
튜터의 가이드 및 해설
네 옵션은 각각 다른 트레이드오프를 갖고 있어요. 한 표로 정리하면 이렇습니다.
| 카테고리 | 신뢰성 | 사용자 경험 | 캐릭터 일관성 | 비용 / 지연 |
|---|---|---|---|---|
| A. "모르겠어요" 솔직 | 매우 높음 | 보통 (성의 없게 느껴질 위험) | 캐릭터 톤이 담긴 답변이면 자연스러움 | 낮음 |
| B. 명확화 요청 | 높음 | 높음 (협력 시그널) | 캐릭터 톤과 잘 어울림 | 낮음 (한 차례 응답 추가) |
| C. 외부 MCP 호출 | 매우 높음 (출처 포함) | 높음 (실제 정보로 답) | 캐릭터가 "외부 도구를 쓰는 그림" 에 어울리는 톤이면 OK | 높음 (MCP 호출 비용·지연·실패 가능성) |
| D. 일반 지식 추측 | 낮음 (환각 위험) | 높음 (응답 풍성) | 깨지기 쉬움 (세계관 밖 답변) | 낮음 |
Option A — 솔직 답변: 신뢰성 1순위. "제가 가진 자료 안에서는 그 답을 찾기 어려워요" 같은 톤이에요. 단 너무 자주 나오면 사용자 경험이 떨어질 수 있어요. ARIA 의 차분한 톤과는 자연스럽게 어울려요.
Option B — 명확화 요청: A 의 약점을 보완하는 선택지예요. "혹시 ___ 에 대한 질문인가요?" 형으로 사용자에게 다시 한 번 협력 시그널을 보내는 접근이에요. 사용자가 검색어를 조정해 다시 질문하면 검색이 성공할 가능성이 높아져요. ARIA 톤과 가장 자연스러운 조합.
Option C — 외부 MCP 호출: 다음 시간 (Day 17) 에서 다루는 내용이에요. 검색이 KB 밖으로 확장되는 구조 — 위키 / 웹 검색 / 사내 다른 vector store 등. 신뢰성 측면에서는 가장 강력하지만 호출 비용과 지연이 늘어나요. "외부 도구를 쓰는 캐릭터" 디자인 결정이 선행되어야 자연스럽게 적용할 수 있어요.
Option D — 일반 지식 추측: 가장 위험한 선택이에요. "세계관 밖에서도 LLM 의 일반 지식으로 답한다" 는 접근이에요. 응답 풍성도는 올라가지만 환각 위험이 폭발하고 캐릭터 일관성이 깨지기 쉬워요. "ARIA 가 세계관 밖 지식을 안다" 는 모순이 생길 위험이 있어요.
현업에서는 보통: A + B 의 조합이 디폴트예요. "제가 가진 자료 안에서는 직접적인 답을 찾기 어려워요. 혹시 ___ 에 대한 질문이실까요?" 같은 한 응답에 솔직 + 명확화 두 가지를 함께 담는 접근이에요. C 는 비용·지연·실패율 트레이드오프가 커지기 때문에 KB 가 작은 시점 에 도입해요. D 는 프로덕션 운영의 위험 구간 — 환각이 한 번 발생하면 사용자 신뢰가 깨지기 때문에 디폴트로 두지 않아요.
ARIA 의 "세계관 안에서 신중하게 답하는" 톤은 A + B 와 자연스럽게 어울려요. "세계관 밖에서도 답해 줘" 는 디자인 결정 — 사용자가 그 선을 명시적으로 요구할 때만 D 를 활성화하는 게 안전해요.
면접관을 홀리는 핵심 멘트
"검색 0 건은 모델 한계의 신호 예요. 무리해서 일반 지식으로 추측하면 환각 위험이 폭발하고, 모른다고 솔직히 답하는 것이 신뢰의 첫걸음이에요. fallback 전략은 응답 안 하는 것이 아니라 정확하게 모름을 표현하면서 명확화 시그널을 함께 보내는 절차이고, 캐릭터 톤이 그 위에 자연스럽게 얹히도록 디자인합니다."
주제 2. 청크 크기 vs 모델 컨텍스트 — Lost in the Middle
문제 상황 요약
본 강의는 청크 500 × top-5 = 약 2,500 토큰 컨텍스트 구성이에요. 그런데 운영 모델은 컨텍스트가 훨씬 커요 — Gemini 2.5 Flash 1M, Claude 4 200K 같은 규모. "큰 컨텍스트 = 많이 넣기" 가 직관이지만, Liu et al. 2024 의 "Lost in the Middle" 연구가 긴 컨텍스트의 중간 위치 정보가 통계적으로 망각되는 현상 을 보고했어요. 청크 200 / 500 / 1000 × top-3 / 5 / 10 의 어느 조합이 답변 품질 × 비용 의 균형점인지의 질문이에요.
튜터의 가이드 및 해설
"Lost in the Middle" 의 핵심 발견은 두 가지예요. 첫째, 컨텍스트 맨 앞과 맨 뒤의 정보는 LLM 이 잘 회수해요. 둘째, 중간 위치의 정보는 통계적으로 무시 되는 경향이 있어요. 이 발견이 "청크를 많이 넣는다" 는 직관과 충돌하는 지점이에요. 청크 N 개를 넣었을 때 정답 청크가 중간에 위치하면 LLM 이 못 보는 거예요.
세 후보의 조합을 한 표로 정리하면 이렇습니다.
| 조합 | 검색 메트릭 | 답변 품질 | 비용 (토큰) | Lost-in-Middle 위험 |
|---|---|---|---|---|
| A. 청크 작음 + top-k 작음 (200 × 3) | precision 높음 / recall 보통 | 정확한 단답에 강함 | 매우 낮음 (~600 토큰) | 매우 낮음 (위치 수 적음) |
| B. 청크 큼 + top-k 작음 (1000 × 3) | precision 낮음 / recall 높음 | 문맥 풍부, 추상 질문에 강함 | 보통 (~3000 토큰) | 낮음 |
| C. 청크 작음 + top-k 큼 (200 × 20) | recall 매우 높음 | Lost-in-Middle 위험 폭발, 답변 품질 떨어짐 | 보통 (~4000 토큰) | 매우 높음 |
| D. 청크 큼 + top-k 큼 (1000 × 10) | recall 매우 높음 | 비용 폭발 + Lost-in-Middle 위험 | 매우 높음 (~10000 토큰) | 매우 높음 |
Option A — 청크 작음 + top-k 작음: 사실 단답 질문 ("NOA 의 외형은?") 에 가장 강한 조합이에요. precision 이 높아서 답이 정확하고, top-k 가 작아서 Lost-in-Middle 위험도 거의 없어요. 비용도 가장 낮은 옵션.
Option B — 청크 큼 + top-k 작음: 추상 / 비교 질문 ("NOA 와 ARIA 의 성격 차이는?") 에 강한 조합이에요. 한 청크 안에 문맥이 풍부해서 LLM 이 비교·추론할 자료가 충분해요. Lost-in-Middle 위험도 낮아요.
Option C — 청크 작음 + top-k 큼: 직관적으로 "recall 을 최대로" 하고 싶을 때 택하게 되지만, "Lost in the Middle" 의 함정이에요. 정답 청크가 중간에 묻히면 LLM 이 못 봐서 답변 품질이 오히려 떨어져요.
Option D — 청크 큼 + top-k 큼: 비용도 폭발하고 Lost-in-Middle 위험도 폭발해요. 운영에서는 거의 쓰이지 않는 조합이에요.
현업에서는 보통: A 또는 B 가 디폴트예요. "큰 컨텍스트 = 많이 넣기" 가 매력적이지만 실험 결과는 반대 — 적은 청크 + 정확한 위치 + 답변 LLM-as-judge 평가 의 짝패 위에서 답변 품질이 올라가는 구조예요. 질문 유형 라우팅 으로 사실 질문은 A, 추상 질문은 B 로 나누는 방법이 안전해요.
그리고 본 강의의 recall@k / precision@k 는 검색 의 품질이지 답변 의 품질이 아니라는 점을 꼭 기억하세요. 답변 품질 평가는 다른 차원 — LLM-as-judge (작은 LLM 으로 답변의 정확성·관련성·완결성을 평가) 구조가 필요해요. 운영 메트릭은 두 차원 (검색 메트릭 + 답변 메트릭) 을 짝패로 구성하는 과정이 본질이에요.
면접관을 홀리는 핵심 멘트
"큰 컨텍스트 가 많이 넣기 의 면허가 아니에요. Lost in the Middle 이후 청크 N 의 위치 가 검색 메트릭만큼 중요해졌어요. 답변 품질의 본질은 적은 청크 + 정확한 위치 + 답변 LLM-as-judge 평가 의 짝패 위에서 올라가고, 검색 메트릭 (recall@k / precision@k) 과 답변 메트릭은 서로 다른 차원이라는 점을 인식하고 두 축을 함께 보는 것이 운영의 본질입니다."
주제 3. metadata 자동 추론 vs 명시적 manifest — 운영의 선택
문제 상황 요약
본 강의는 파일명 첫 토큰 으로 character_id 를 자동 추론하는 컨벤션이에요. 학습용 lab 으론 단순 + 깔끔하지만 운영에서는 명시적 manifest (frontmatter 또는 manifest.yml) 가 더 안전한 방향으로 가게 돼요. "NOA 와 ARIA 가 함께 등장하는 에피소드" 같은 다중 캐릭터 자료는 어느 character_id 로 분류해야 하는가의 모호함이 생기는 지점이에요.
운영 안전성 vs 학습용 단순성 사이에서 본 강의의 어디까지를 자동 추론으로 가고, 어디부터 명시적 manifest 로 넘어가야 하는지의 질문이에요.
튜터의 가이드 및 해설
세 후보를 한 표로 정리하면 이렇습니다.
| 옵션 | 설정 비용 | 오류 비용 | 다중 캐릭터 자료 | 운영 안전성 |
|---|---|---|---|---|
| A. 파일명 컨벤션만 | 매우 낮음 (파일만 넣으면 끝) | 높음 (파일명을 잘못 지으면 잘못 분류) | 불가능 (단일 캐릭터 전용 가정) | 낮음 |
| B. frontmatter 명시 + 파일명 fallback | 보통 (자료마다 frontmatter 작성) | 낮음 (명시된 자료는 안전) | 가능 (character_id: [NOA, ARIA] 리스트) |
높음 |
C. manifest.yml 강제 |
높음 (모든 자료를 manifest 에 등록) | 매우 낮음 (이중 검증) | 가능 + 명시 | 매우 높음 |
Option A — 파일명 컨벤션만 (본 강의): "컨벤션 over 컨피그" 의 학습용 lab 이에요. 장점은 파일만 잘 넣으면 자동 분류 되는 단순함. 단점은 파일명을 잘못 지으면 잘못 분류 되는 것과 다중 캐릭터 자료를 표현 못하는 점 두 가지예요. KB 가 작고 단일 캐릭터 자료만 있는 시점 에는 가장 빠른 선택지예요.
Option B — frontmatter 명시 + 파일명 fallback: 운영의 정석이에요. 자료 상단에 아래와 같이 작성합니다.
---
character_id: [NOA, ARIA, COMMON]
tags: [에피소드, 도서관, 협력]
priority: high
---
# NOA 와 ARIA 의 도서관 에피소드
...
장점은 두 가지 — (1) 단일 캐릭터 자료는 frontmatter 한 줄로 명시, (2) 다중 캐릭터 자료는 리스트로 표현 가능. 검색 시 in('character_id', requested) 자동 분기로 동작해요. frontmatter 가 없으면 파일명 fallback 으로 떨어져서 기존 자료 의 호환성도 유지돼요.
Option C — manifest.yml 강제: 운영 안전성 최대. 모든 KB 자료가 manifest.yml 에 명시되어 외부 자료 (파일명만 있고 등록 안 됨) 가 KB 에 끼지 못하는 구조예요. 단점은 설정 비용 — 자료 추가할 때마다 manifest 도 업데이트해야 해요. KB 가 자라는 속도가 느리고 오류 비용이 폭발적인 도메인 (예: 의료·법률) 에서만 도입하는 접근이에요.
현업에서는 보통: B + 부분 A 의 조합이에요. 단일 캐릭터 자료 는 파일명 컨벤션 OK (학습용 + 빠른 추가), 다중 캐릭터 자료 / 예외 케이스 는 frontmatter 를 작성하는 절차예요. 모든 KB 에 manifest 강제 는 과잉 — KB 가 자라는 속도를 오히려 막게 돼요.
본 강의 어디까지 자동 추론으로 가야 하는가의 답은 KB 자라는 속도 + 오류 비용 의 짝패 위에서 정해져요. 학생이 직접 KB 를 추가하는 단계 (Day 16 과제 2 같은 곳) 는 파일명 컨벤션이 자연스러워요. 운영 KB 가 자라기 시작 (월 100 자료+) 하면 frontmatter 도입이 자연스러운 시점이에요. 의료·법률처럼 오류 비용이 폭발적 이면 manifest 강제가 맞는 접근이에요.
다중 캐릭터 자료 ("NOA 와 ARIA 가 함께 등장하는 에피소드") 의 모호함은 frontmatter 의 character_id: [NOA, ARIA, COMMON] 리스트 형으로 해결돼요. 본 강의의 resolveCharacterId 가 문자열 단일값 으로 되어 있다면, 운영 진화 시 리스트 / Optional 로 확장하는 게 자연스러운 다음 단계예요.
면접관을 홀리는 핵심 멘트
"컨벤션 over 컨피그 가 모든 상황에 맞는 건 아니에요. KB 자라는 속도 와 오류 비용 의 짝패 위에서 방향이 정해져요. 학습용 lab 의 파일명 컨벤션이 운영의 frontmatter 명시로 자연스럽게 확장되고, 도메인의 오류 비용이 폭발적이면 manifest 강제까지 가는 흐름이에요. 어디까지 자동 추론으로 갈 것인가 의 답은 KB 라이프사이클의 어느 단계인가 의 답과 짝패로 정해집니다."