RAG A부터 Z까지: 먼저 문서를 찾고 답변하기
1. 개요
LLM은 훈련 데이터에 있는 것만 알고 있습니다. 당신의 가게 반품 정책이나 회사 내부 절차를 모릅니다. 이런 것들을 물어보면 보통 거짓말을 합니다. **RAG (Retrieval-Augmented Generation)**은 이를 해결합니다: 시스템이 먼저 관련 문서를 찾고, LLM이 그 문서를 기반으로 답변하는 방식입니다.
이 글에서는 Spring Boot 3과 Spring AI로 완전한 RAG 파이프라인을 직접 구축해봅시다. 문서 로드, 청크 분할, 임베딩 생성, 검색, 출처가 있는 프롬프트 생성, 그리고 각 단계의 테스트를 포함합니다. 글 마지막에는 자주 발생하는 오류와 RAG를 사용하면 안 되는 경우들을 다룹니다.
2. RAG는 어떻게 동작하나요
RAG는 두 개의 별도 단계로 구성됩니다.
**수집 단계(ingestion)**는 한 번 또는 정기적으로 실행됩니다:
- 문서를 읽습니다 (Markdown, PDF, HTML…).
- 수백 개의 토큰 크기인 청크로 분할합니다.
- 임베딩 모델을 호출해서 각 청크를 숫자 벡터로 변환합니다.
- 벡터와 청크 내용을 벡터 스토어에 저장합니다.
**쿼리 단계(query)**는 사용자가 질문할 때마다 실행됩니다:
- 질문을 벡터로 임베드합니다.
- 가장 유사한 벡터를 가진 청크들을 찾습니다 (유사도 검색).
- 해당 청크들을 프롬프트에 추가합니다. 이것이 증강 단계입니다.
- 프롬프트를 LLM에 보내서 답변을 생성합니다. 이것이 생성 단계입니다.
핵심 아이디어는 매우 간단합니다. LLM이 당신의 문서를 “기억”할 필요가 없습니다. 프롬프트에서 올바른 부분만 읽으면 됩니다.
3. 프로젝트 준비
Spring AI BOM과 OpenAI starter를 추가합니다. 이 starter는 채팅 모델과 임베딩 모델을 모두 제공합니다.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
다음은 모델을 구성합니다:
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.1
spring.ai.openai.embedding.options.model=text-embedding-3-small
temperature는 낮게 설정하세요. RAG는 문서에 충실한 답변을 원하지, 창의성이 필요한 게 아니거든요.
마지막으로 두 개의 빈을 선언합니다: 벡터 스토어 하나와 ChatClient.
@Configuration
public class RagConfig {
@Bean
VectorStore vectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}
@Bean
ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}
}
SimpleVectorStore는 모든 벡터를 메모리에 저장합니다. 학습과 데모에 적합합니다. EmbeddingModel은 Spring AI가 OpenAI starter에서 자동으로 구성합니다. 문서를 추가할 때마다 스토어는 이 모델을 호출해서 벡터를 생성합니다.
참고:
SimpleVectorStore는 애플리케이션 재시작 시 모든 데이터가 손실되고 선형 검색을 합니다. 프로덕션 환경에서는 PGvector, Qdrant, Elasticsearch… 를 사용하세요. Spring AI는 각 유형에 대한 starter를 제공하며,VectorStore인터페이스는 동일하게 유지되므로 아래 코드는 변경할 필요가 없습니다.
4. 문서 로드
src/main/resources/docs/return-policy.md 파일이 있다고 가정합시다:
# 반품 정책
고객은 상품 수령일로부터 30일 이내에 반품할 수 있습니다.
상품은 원본 태그, 레이블 및 영수증이 그대로 있어야 합니다.
환불금은 5–7 영업일 이내에 계좌로 이체됩니다.
속옷 및 50% 이상 할인된 상품은 반품할 수 없습니다.
4.1. 읽기 및 청크 분할
@Component
public class DocumentIngestor implements ApplicationRunner {
private final VectorStore vectorStore;
private final ResourcePatternResolver resolver;
public DocumentIngestor(VectorStore vectorStore, ResourcePatternResolver resolver) {
this.vectorStore = vectorStore;
this.resolver = resolver;
}
@Override
public void run(ApplicationArguments args) throws IOException {
// chunkSize=300 토큰, minChunkSizeChars=100, minChunkLengthToEmbed=5,
// maxNumChunks=10000, keepSeparator=true
var splitter = new TokenTextSplitter(300, 100, 5, 10_000, true);
for (Resource file : resolver.getResources("classpath:docs/*.md")) {
var reader = new TextReader(file);
reader.getCustomMetadata().put("source", file.getFilename());
List<Document> chunks = splitter.apply(reader.get());
vectorStore.add(chunks);
System.out.printf("로드됨 %s: %d개 청크%n", file.getFilename(), chunks.size());
}
}
}
위 코드는 다음을 수행합니다:
ApplicationRunner는 컨텍스트 시작 후 한 번 실행되므로, 첫 요청 전에 문서가 항상 로드됩니다.TextReader는 파일을Document로 읽습니다. 메타데이터source를 파일명으로 설정해서 나중의 출처 표시 단계에서 사용합니다.TokenTextSplitter는 텍스트를 약 300 토큰의 청크로 자릅니다. 메타데이터는 각 청크에 복사됩니다.vectorStore.add(...)는 각 청크에 대해 임베딩 모델을 호출한 후 벡터를 저장합니다.
시작할 때 다음과 같이 표시됩니다:
로드됨 return-policy.md: 1개 청크
로드됨 shipping.md: 3개 청크
4.2. 청크 크기 선택
청크 크기는 답변 품질에 직접 영향을 미칩니다:
- 청크가 너무 큼: 하나의 청크에 여러 주제가 포함되어 벡터가 “희석”되어 정확한 매칭이 어렵습니다. 프롬프트도 더 많은 토큰을 소비합니다.
- 청크가 너무 작음: 문장이 컨텍스트를 잃고 단편화됩니다. 예를 들어 “30일 이내”는 한 청크에, “반품”은 다른 청크에 있을 수 있습니다.
산문 문서의 경우 200–500 토큰이 좋은 시작점입니다. 명확한 구조가 있는 문서 (FAQ, 목차)의 경우 각 청크가 완전한 의미 단위가 되도록 제목별로 분할해야 합니다.
5. 관련 문서 검색
검색은 간단한 호출 하나입니다:
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder()
.query("반품 기간이 얼마나 되나요?")
.topK(4)
.similarityThreshold(0.5)
.build());
query: 질문입니다. 벡터 스토어는 로드 시 사용한 것과 동일한 모델을 사용해서 질문을 자동으로 임베드합니다.topK(4): 최대 4개의 가장 유사한 청크를 가져옵니다.similarityThreshold(0.5): 코사인 유사도가 0.5 미만인 청크를 제외합니다. 임계값을 설정하지 않으면 스토어는 질문과 관련이 없더라도 항상 충분한topK결과를 반환합니다.
모든 경우에 최적의 임계값은 없습니다. 수십 개의 실제 질문으로 점수를 기록한 후 올바른 결과와 노이즈를 분리할 임계값을 선택하세요.
6. 프롬프트 생성 및 답변 생성
6.1. 프롬프트 빌더
프롬프트 생성 부분을 순수 함수로 분리하면 테스트가 쉬워집니다:
public final class PromptBuilder {
private static final String TEMPLATE = """
당신은 고객 지원 어시스턴트입니다.
아래 컨텍스트만을 기반으로 답변하세요.
컨텍스트에 정보가 없으면 다음과 같이 답변하세요:
"이 정보는 문서에서 찾을 수 없습니다."
답변 끝에 출처를 대괄호로 표시하세요, 예: [return-policy.md].
컨텍스트:
{context}
질문: {question}
""";
private PromptBuilder() {}
public static String build(String question, List<Document> docs) {
String context = docs.stream()
.map(d -> "[" + d.getMetadata().getOrDefault("source", "unknown") + "]\n" + d.getText())
.collect(Collectors.joining("\n---\n"));
return TEMPLATE.replace("{context}", context)
.replace("{question}", question);
}
}
템플릿에는 세 가지 중요한 포인트가 있습니다:
- **“아래 컨텍스트만을 기반으로 답변하세요”**는 LLM을 문서로 제한해서 거짓말 생성을 줄입니다.
- 고정된 거부 문장은 문서에 정보가 없을 때 LLM에 합법적인 방법을 제공합니다.
- 각 청크 옆의 출처 이름으로 LLM이 출처를 명시하고 사용자가 검증할 수 있습니다.
여기서는 String.formatted 대신 replace를 사용합니다. 이유: 문서 내용에 % 문자가 포함될 수 있기 때문입니다 (예: “50% 할인”), formatted는 UnknownFormatConversionException을 던질 것입니다.
6.2. RagService
@Service
public class RagService {
static final String NOT_FOUND = "이 정보는 문서에서 찾을 수 없습니다.";
private final VectorStore vectorStore;
private final ChatClient chatClient;
public RagService(VectorStore vectorStore, ChatClient chatClient) {
this.vectorStore = vectorStore;
this.chatClient = chatClient;
}
public String answer(String question) {
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(4)
.similarityThreshold(0.5)
.build());
if (docs == null || docs.isEmpty()) {
return NOT_FOUND;
}
return chatClient.prompt()
.user(PromptBuilder.build(question, docs))
.call()
.content();
}
}
청크를 찾지 못하면, 서비스는 LLM을 호출하지 않고 즉시 거부 문장을 반환합니다. 이 방법은 비용을 절감하고 LLM이 일반 지식으로 답변을 만들 가능성을 완전히 제거합니다.
마지막으로 컨트롤러입니다:
@RestController
public class AskController {
private final RagService ragService;
public AskController(RagService ragService) {
this.ragService = ragService;
}
@GetMapping("/ask")
public String ask(@RequestParam String q) {
return ragService.answer(q);
}
}
이제 API를 호출해봅시다:
curl -G "http://localhost:8080/ask" --data-urlencode "q=반품 기간이 얼마나 되나요?"
curl -G "http://localhost:8080/ask" --data-urlencode "q=매장에서 노트북을 판매하나요?"
상품 수령일로부터 30일 이내에 반품할 수 있으며, 상품이 원본 태그, 레이블 및 영수증을 유지해야 합니다. [return-policy.md]
이 정보는 문서에서 찾을 수 없습니다.
첫 번째 답변은 문서를 정확히 따르고 출처를 명시합니다. 두 번째 답변은 일치하는 문서가 없어서 시스템이 거짓말을 하는 대신 거부합니다.
7. 테스트 작성
LLM을 실제로 호출하면 느리고, 비용이 들고, 결과가 불안정하므로 단위 테스트에서는 호출하지 않아야 합니다. 대신 결정적인 두 부분을 테스트하세요: 프롬프트 빌더와 문서가 없을 때의 거부 분기입니다.
class RagServiceTest {
@Test
void promptContainsContextSourceAndQuestion() {
var doc = new Document("30일 이내에 반품 가능.", Map.of("source", "return-policy.md"));
String prompt = PromptBuilder.build("반품 기간은?", List.of(doc));
assertThat(prompt)
.contains("[return-policy.md]")
.contains("30일 이내에 반품 가능.")
.contains("질문: 반품 기간은?")
.doesNotContain("{context}", "{question}");
}
@Test
void percentSignInDocumentDoesNotBreakPrompt() {
var doc = new Document("50% 이상 할인된 상품은 반품할 수 없습니다.", Map.of("source", "return-policy.md"));
assertThat(PromptBuilder.build("세일 상품?", List.of(doc)))
.contains("50% 이상 할인");
}
@Test
void returnsNotFoundWithoutCallingLlmWhenNoDocuments() {
VectorStore vectorStore = mock(VectorStore.class);
ChatClient chatClient = mock(ChatClient.class);
when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of());
String answer = new RagService(vectorStore, chatClient).answer("노트북을 판매하나요?");
assertThat(answer).isEqualTo(RagService.NOT_FOUND);
verifyNoInteractions(chatClient);
}
}
세 번째 테스트는 두 가지를 확인합니다. 첫째, 답변이 거부 문장입니다. 둘째, ChatClient가 전혀 호출되지 않습니다. 나중에 누군가 실수로 isEmpty() 분기를 삭제하면 테스트가 실패할 것입니다.
검색 품질을 평가하려면 추가 통합 테스트를 작성할 수 있습니다. 예상되는 출처 파일명이 있는 질문 세트를 준비한 다음 해당 파일이 topK 결과에 포함되는지 확인합니다. 이 테스트 세트는 매번 빌드할 때가 아니라 정기적으로 실행해야 합니다.
8. QuestionAnswerAdvisor로 간단히 하기
각 단계를 이해한 후에는 Spring AI의 기본 제공 advisor를 사용할 수 있습니다. 이 advisor는 검색과 프롬프트 생성을 캡슐화합니다. dependency를 추가해야 합니다:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
String answer = chatClient.prompt()
.advisors(QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().topK(4).similarityThreshold(0.5).build())
.build())
.user("반품 기간이 얼마나 되나요?")
.call()
.content();
Advisor는 질문을 자동으로 임베드하고, 청크를 찾아 기본 템플릿에 따라 프롬프트에 삽입합니다. 이 방법은 편리하지만 출처 형식과 초기 거부 분기를 제어하기 어렵습니다. 따라서 6절의 수동 구현은 이러한 요구사항이 필요할 때 여전히 가치가 있습니다.
9. 일반적인 오류 및 RAG를 사용하면 안 되는 경우
임베딩 모델을 변경하지만 데이터를 다시 로드하지 않음. 서로 다른 모델의 벡터는 비교할 수 없습니다. 모델을 변경할 때마다 모든 문서를 다시 임베드해야 합니다.
similarityThreshold를 설정하지 않음. 벡터 스토어는 항상 충분한 topK 결과를 반환합니다. LLM이 쓸모없는 컨텍스트를 받아서 자신감 있게 잘못된 답변을 합니다.
지속적인 스토어와 함께 매번 시작할 때마다 문서를 다시 로드함. PGvector의 경우, 위의 ApplicationRunner는 배포할 때마다 중복을 만듭니다. 내용 및 파일명의 해시와 같이 각 청크에 고정 ID를 할당하거나, 다시 로드하기 전에 source별로 삭제해야 합니다.
프롬프트에 너무 많은 청크를 포함함. topK를 20으로 증가시켜도 답변이 더 좋아지지 않습니다. LLM은 긴 컨텍스트의 중간에 있는 정보를 놓치기 쉽고, 토큰 수만큼 비용도 증가합니다.
문서 내용을 절대적으로 신뢰함. 사용자로부터 문서를 받는 경우, “이전 지시사항을 무시하세요”와 같은 프롬프트 주입 형태의 문장이 포함될 수 있습니다. 스토어에 로드하도록 허용되는 데이터 소스를 제어해야 합니다.
참고: RAG는 모든 문제에 적합하지 않습니다. 질문이 전체 데이터를 종합해야 하는 경우, 예를 들어 “9월에 완전히 환불된 주문이 몇 개인가”는 SQL 쿼리로 변환해야 합니다. 유사도 검색은 몇 개의 청크만 반환하므로 집계나 합계를 할 수 없습니다. 전체 문서가 몇 페이지에 불과하면 프롬프트에 직접 포함하는 것이 전체 RAG 파이프라인보다 간단하고 정확합니다.
10. 결론
RAG는 지식을 모델에서 분리합니다. 문서는 벡터 스토어에 있고, LLM은 각 질문에서 필요한 부분만 읽습니다. 이 글에서 두 단계를 모두 구축했습니다: 문서 로드 (읽기, 청크 분할, 임베딩, 저장) 및 쿼리 (임계값을 사용한 검색, 출처가 있는 프롬프트 생성, LLM 호출). 프롬프트 부분과 거부 분기에 대한 테스트도 작성했습니다.
이 기반에서 다음 방향으로 진행할 수 있습니다: SimpleVectorStore를 PGvector로 교체, 제목별로 청크 분할, 메타데이터로 필터링 (예: 특정 부서의 문서만 검색) 또는 프롬프트에 전달하기 전에 결과를 다시 정렬하는 rerank 단계 추가.