LLM이 정확한 답변을 위해 증거가 필요: RAG 상세 설명과 실제 예제
1. 개요
어떤 LLM이 아무리 강력해도 당신 회사의 내부 문서 내용을 자동으로 알지 못합니다 — 입력으로 제공되는 것만 알 뿐입니다. 이 글은 Retrieval-Augmented Generation (RAG) 메커니즘을 설명합니다: 왜 필요한지, retrieval이 어떻게 작동하는지, 그리고 답변 품질이 텍스트 생성 전의 검색 단계에 거의 전적으로 의존하는 이유를 다룹니다. 이 글을 읽고 나면 RAG 시스템의 데이터 흐름을 명확히 이해하고 Java로 간단한 retrieval 파이프라인을 직접 작성할 수 있게 될 것입니다.
2. 문제: LLM은 개인 데이터에 대한 “메모리”가 없습니다
모델은 특정 시점에 고정된 공개 데이터셋으로 학습됩니다. 지난주 당신 회사에서 발표한 새로운 환불 정책을 알지 못하고, 특정 내부 계약서의 내용도 알 수 없습니다.
개인 정보를 입력에 추가하는 두 가지 방법이 있습니다:
- 프롬프트에 직접 삽입 — 문서가 작고 context window에 맞을 때만 가능합니다.
- 검색 (retrieval) — 문서 세트가 클 때, 애플리케이션은 관련성이 가장 높은 몇 개 섹션만 선택한 후 프롬프트에 추가합니다.
// 방법 1: 전체 문서를 강제로 삽입 — 짧은 텍스트에만 적합함
String prompt = """
다음 정책에 따라 고객의 질문에 답하세요.
정책: %s
질문: %s
""".formatted(fullPolicyText, userQuestion);
이 방식은 간단하지만 확장성이 없습니다: context window는 토큰 수 제한이 있으며, 관련 없는 텍스트를 많이 넣을수록 모델은 “길을 잃기” 쉽습니다 (lost in the middle). 문서 세트가 수천 페이지에 달할 때는 필수적으로 retrieval을 사용해야 합니다.
3. RAG가 무엇이고 어떻게 작동하는가
RAG (Retrieval-Augmented Generation)는 문제를 두 개의 독립적인 단계로 나눕니다:
- Retrieval: 질문과 관련된 올바른 텍스트 섹션을 찾습니다.
- Generation: LLM은 이 섹션들을 사용하여 답변을 생성하고 출처를 인용합니다.
사용자 질문
│
▼
[Embedding model] ── 질문을 벡터로 변환
│
▼
[Vector database] ── 의미가 가장 유사한 텍스트 섹션 찾기
│
▼
[원본 Passages] ── 원문을 프롬프트에 추가
│
▼
[LLM] ── 답변 + 출처 인용 생성
핵심 포인트: LLM은 vector database에 직접 “접근”하지 않습니다. Retrieval이 완료된 후 애플리케이션이 제공하는 일반 텍스트만 받습니다. Vector database는 검색을 지원하는 도구일 뿐, 모델이 읽을 “지식”을 저장하는 곳이 아닙니다.
3.1. Embedding — 질문을 숫자로 변환
Embedding 모델은 텍스트 섹션을 실수 벡터 (예: 1536차원)로 변환합니다. 의미가 유사한 두 텍스트는 그 공간에서 가까운 벡터를 생성합니다.
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
@Service
public class PolicyEmbeddingService {
private final EmbeddingModel embeddingModel;
public PolicyEmbeddingService(EmbeddingModel embeddingModel) {
this.embeddingModel = embeddingModel;
}
public float[] embed(String text) {
EmbeddingResponse response = embeddingModel.embedForResponse(List.of(text));
return response.getResult().getOutput();
}
}
embed()는 텍스트 섹션 (예: “호텔 객실 취소 정책은 어떻게 되나요?“라는 질문)을 받고 float[] 배열을 반환합니다 — 해당 질문의 의미를 나타내는 벡터입니다. 이것이 vector database에 이미 저장된 벡터와 비교되는 것입니다.
3.2. Vector database — 의미가 가장 유사한 텍스트 섹션 찾기
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
@Service
public class PolicyRetrievalService {
private final VectorStore vectorStore;
public PolicyRetrievalService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public void indexPolicy(String policyId, String content) {
Document document = new Document(content, Map.of("policyId", policyId));
vectorStore.add(List.of(document));
}
public List<Document> search(String question) {
SearchRequest request = SearchRequest.query(question).withTopK(3);
return vectorStore.similaritySearch(request);
}
}
indexPolicy()는 정책 섹션을 vector database에 저장하고, 메타데이터 (policyId)를 포함하여 나중에 이 텍스트가 어느 문서에 속하는지 알 수 있도록 합니다.search()는 사용자 질문을 받고, 내부적으로 embedding을 수행한 후 가장 유사한 3개 섹션 (topK(3))을 찾습니다.
search("호텔 객실 취소 정책은 어떻게 되나요?")의 결과:
1. "고객은 체크인 24시간 전까지 객실을 무료로 취소할 수 있습니다..."
2. "환불 불가 객실 예약의 경우 (non-refundable)..."
3. "5명 이상의 단체 예약 객실 취소 수수료가 적용됩니다..."
3.3. Generation — LLM이 증거를 바탕으로 답변
@Service
public class PolicyAnswerService {
private final ChatClient chatClient;
private final PolicyRetrievalService retrievalService;
public PolicyAnswerService(ChatClient.Builder builder,
PolicyRetrievalService retrievalService) {
this.chatClient = builder.build();
this.retrievalService = retrievalService;
}
public String answer(String question) {
List<Document> passages = retrievalService.search(question);
String context = passages.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n---\n"));
String prompt = """
다음 텍스트 섹션만을 바탕으로 답변하세요. 정보가 충분하지 않으면 모른다고 명확히 말씀하세요.
텍스트 섹션:
%s
질문: %s
""".formatted(context, question);
return chatClient.prompt(prompt).call().content();
}
}
“다음 텍스트 섹션만을 바탕으로 답변하세요”라는 지시는 중요합니다 — 이것은 hallucination을 줄이기 위한 중요한 제약입니다: 모델이 학습된 지식에서 추가 내용을 만들어내는 대신 증거(evidence)에 충실하도록 하는 것입니다.
주의: “context만을 바탕으로”라는 명확한 제약을 설정하지 않으면, 모델은 여전히 학습된 배경 지식과 retrieval된 콘텐츠를 섞을 수 있으며, 이는 실제 문서와 모순되는 그럴듯해 보이는 답변을 초래할 수 있습니다.
4. 왜 retrieval이 나쁘면 답변도 나쁜가
이것은 종종 간과되는 부분입니다: LLM은 수신한 것을 해석하는 데는 능하지만, 그 정보가 정확한지, 충분한지 자가 검사할 방법이 없습니다.
두 가지 일반적인 시나리오:
시나리오 1 — Retrieval이 오래된 문서를 가져옵니다. Vector database가 여전히 오래된 정책 버전을 인덱싱하고 있다면 (업데이트되지 않거나 삭제되지 않음), 시스템은 오래된 텍스트를 반환합니다. LLM은 그것을 읽고 완전히 자신감 있게 답변합니다 — 하지만 잘못된 답변입니다.
시나리오 2 — Retrieval이 중요한 맥락을 놓칩니다. 원본 문서의 구조를 생각해봅시다:
제5조. 객실 취소 정책
고객은 체크인 24시간 전까지 객실을 무료로 취소할 수 있습니다.
제5.1조. 예외
설날 연휴 기간 객실 예약의 경우, 무료 취소가 적용되지 않습니다;
고정 취소 수수료는 예약 가격의 50%입니다.
문서를 작은 청크(chunking)로 나눌 때 “제5조”와 “제5.1조”를 별개의 청크로 나누면, retrieval이 “제5조”만 가져올 수 있습니다 (질문에 “설날” 또는 “예외”라는 단어가 없기 때문에). 그러면 LLM은 “24시간 전에 무료로 취소할 수 있습니다”라고 답변합니다 — 매우 그럴듯해 보이지만, 중요한 예외 조건을 빠뜨렸습니다.
@Test
void 예외_청크가_누락되면_LLM이_조건을_무시한다() {
// Retrieval이 1개 청크만 반환하는 것을 시뮬레이션, 예외 부분이 누락됨
List<Document> incompletePassages = List.of(
new Document("고객은 체크인 24시간 전까지 객실을 무료로 취소할 수 있습니다.")
);
String answer = fakeAnswerService.answerFromPassages(
"설날 연휴 기간에 예약한 객실을 취소하면 수수료가 드나요?",
incompletePassages
);
// 답변은 예외 조건에 대한 증거가 부족하여 잘못될 것입니다
assertThat(answer).doesNotContain("50%");
}
이 테스트는 LLM의 오류를 찾기 위한 것이 아닙니다 — retrieval이 문제임을 보여주는 것입니다. 충분한 증거 (두 청크 모두)를 제공하면, 심지어 중간 수준의 모델도 올바른 답변을 할 것입니다.
5. Chunking: retrieval을 “정확한 수준의 상세함”으로 만드는 열쇠
위 예제에서 보듯이 chunking이 잘못되면 심각한 후유증을 낳습니다. RAG를 구축할 때 여기에 가장 많은 투자를 해야 합니다.
@Service
public class PolicyChunkingService {
public List<String> splitByHeading(String document) {
// "제X조" 또는 "제X.Y조" 패턴으로 나누어 부모-자식 맥락을 유지합니다
String[] rawChunks = document.split("(?=제\\d+)");
return Arrays.stream(rawChunks)
.map(String::trim)
.filter(chunk -> !chunk.isBlank())
.toList();
}
}
고정된 문자 수로 나누는 대신 (예: 500글자마다 자르기 — 조항과 예외 사이를 자를 수 있음), 이 함수는 의미 있는 경계 (“제X조”)에 따라 나눕니다. 이 방식은 각 청크가 완전한 정보 단위를 유지하도록 하며, 주 규칙과 그 예외를 분리할 위험을 줄입니다.
@Test
void 제목으로_나누기_주규칙과_예외를_함께_포함해야_함() {
String document = """
제5조. 객실 취소 정책
고객은 체크인 24시간 전까지 객실을 무료로 취소할 수 있습니다.
제5.1조. 예외
설날 연휴 기간, 취소 수수료는 고정 50%입니다.
""";
List<String> chunks = chunkingService.splitByHeading(document);
assertThat(chunks).hasSize(2);
assertThat(chunks.get(1)).contains("예외", "50%");
}
주의: 제목으로 나누는 chunking은 명확한 구조가 있는 문서 (계약, 규칙, 기술 문서)에서만 효과적입니다. 자유 형식 텍스트 (이메일, 채팅 로그)의 경우, 다른 전략이 필요합니다 — 예를 들어 문단으로 나누고 겹침을 포함하거나, 인접한 문장 간의 유사성에 따라 의미 있게 나누기 (semantic chunking).
6. 언제 RAG를 사용하면 안 되나
RAG가 모든 것을 해결해주지는 않습니다:
- 작고 고정된 데이터셋: 전체 문서가 단 몇천 토큰에 불과하다면, 프롬프트에 직접 삽입하는 것이 간단하고 retrieval 파이프라인 전체를 구축하는 것보다 오류가 적습니다.
- 복잡한 다단계 추론이 필요한 질문: 답변이 단일 텍스트 섹션에 직접 포함되지 않고, 여러 소스에서 정보를 조합하거나 복잡한 계산이 필요한 경우 — RAG는 “질문과 유사한 섹션”을 찾지, “추론된 답변을 포함하는 섹션”을 찾지 않습니다.
- 초 단위로 변하는 데이터 (주식 가격, 실시간 재고): API를 직접 호출 (function calling)하는 것이 나으며, vector database를 지속적으로 다시 인덱싱하는 것은 비효율적입니다.
7. 결론
LLM은 당신의 개인 데이터를 절대로 “알지” 못합니다 — 제공된 증거를 해석하는 데 뛰어날 뿐입니다. RAG는 이를 해결합니다: retrieval (올바른 텍스트 찾기)을 generation (답변 생성)에서 분리하고, embedding 모델과 vector database를 연결고리로 사용합니다. 최종 답변의 품질은 거의 전적으로 데이터를 얼마나 잘 인덱싱하고 chunking 하느냐에 달려 있습니다 — 강력한 LLM도 형편한 retrieval 단계를 구할 수 없습니다. RAG를 구축한다면, 더 큰 모델로 업그레이드하기 전에 chunking과 retrieval 정확도/재현율을 확인하는 데 시간을 투자하세요.