26/07/30 IL(I Learned) - RAG (2)

Let's take a break·2026년 8월 5일

Spring AI 기반 RAG 구현과 Vector Database 활용

이번에는 RAG의 개념을 이해하는 것에서 나아가 Spring Boot와 Spring AI를 활용하여 실제 RAG 시스템을 구현하는 과정을 학습하였다. 단순히 AI에게 질문을 전달하는 것이 아니라 문서를 Vector Database에 저장하고, 문서를 검색하고, 검색된 결과를 AI에게 전달하여 답변을 생성하는 전체 파이프라인을 구현하였다.

특히 이번 프로젝트에서는 문서를 저장하는 과정(Ingest Pipeline)질문에 대해 관련 문서를 검색하는 과정(Retrieval Pipeline) 을 직접 구현하면서 RAG 시스템이 내부적으로 어떤 단계로 동작하는지 이해할 수 있었다.


Document 객체 생성과 VectorStore 저장

RAG 시스템에서 가장 먼저 수행되는 작업은 문서를 Vector Database에 저장하는 것이다.

이를 위해 Spring AI에서는 Document 객체를 사용하였다.

public void save(String content, String category) {

    Document doc = new Document(
            content,
            Map.of(
                    "category", category,
                    "source", "manual")
    );

    vectorStore.add(List.of(doc));
}

Document는 단순한 문자열을 저장하는 객체가 아니라 텍스트와 메타데이터(Metadata)를 함께 관리하는 객체이다.

이번 프로젝트에서는 Document 생성 시 두 가지 정보를 저장하였다.

  • 실제 문서 내용(content)
  • 메타데이터(category, source)

예를 들어

내용

Spring Boot는...

--------------------

Metadata

category = spring

source = manual

처럼 하나의 Document 안에 저장된다.

이후

vectorStore.add(...)

를 호출하면 Spring AI가 내부적으로 다음 과정을 자동으로 수행한다.

Document

↓

Embedding Model 호출

↓

Vector 생성

↓

Vector Database 저장

즉, 개발자가 Embedding API를 직접 호출하지 않아도 VectorStore.add()만 호출하면 문서 임베딩과 저장이 자동으로 수행된다는 점을 새롭게 학습하였다.


Metadata를 함께 저장하는 이유

이번 프로젝트에서는 Document에 Metadata를 저장하였다.

Map.of(

"category", category,

"source", "manual"

)

Metadata는 문서의 부가 정보를 저장하는 영역이다.

예를 들어

Spring Boot 문서

↓

category = spring
AI 문서

↓

category = ai

처럼 카테고리를 저장할 수 있다.

나중에 검색할 때

AI 관련 문서만 검색

처럼 조건을 줄 수 있기 때문에 단순히 내용을 저장하는 것보다 훨씬 효율적인 검색이 가능하다.

실무에서는

  • 작성자
  • 생성일
  • 문서 종류
  • 권한

등도 Metadata에 저장하여 검색 조건으로 활용한다는 점도 함께 이해하였다.


Similarity Search 구현

문서를 저장한 뒤에는 질문과 가장 유사한 문서를 검색해야 한다.

public List<Document> search(String query) {

    return vectorStore.similaritySearch(

            SearchRequest.builder()

                    .query(query)

                    .topK(4)

                    .similarityThreshold(0.3)

                    .build()

    );

}

similaritySearch()는 입력한 질문을 먼저 Embedding한 뒤 저장된 모든 문서와 유사도를 계산한다.

동작 과정은 다음과 같다.

질문

↓

Embedding

↓

Vector 생성

↓

Vector DB 검색

↓

유사도 계산

↓

Top-K 반환

여기서 중요한 점은 문자열을 비교하는 것이 아니라 벡터 간의 거리(유사도) 를 비교한다는 것이다.

예를 들어

질문

Spring Boot란?

↓

검색 결과

Spring Boot 설명

92%

↓

Spring Framework 설명

88%

↓

Java 문법

35%

처럼 의미가 비슷한 문서를 찾는다.


SearchRequest의 역할

이번 프로젝트에서는 검색 조건을 객체로 관리하였다.

SearchRequest.builder()

.query(query)

.topK(4)

.similarityThreshold(0.3)

SearchRequest에는 검색 조건이 모두 포함된다.

query

검색할 질문

topK

가장 유사한 문서를 몇 개 가져올 것인지 결정한다.

예를 들어

topK = 4

이면

가장 유사한 문서 4개만 AI에게 전달된다.

similarityThreshold

최소 유사도를 의미한다.

예를 들어

0.91

0.85

0.73

0.42

0.21

이라면

Threshold가

0.3

이므로

0.21인 문서는 제외된다.

이러한 설정을 통해 AI에게 너무 관련성이 낮은 문서를 전달하지 않도록 제어할 수 있다는 점을 학습하였다.


문서 적재(Ingest Pipeline)

이번 프로젝트에서 가장 중요하게 학습한 내용 중 하나는 문서를 Vector Database에 적재하는 과정이었다.

List<Document> docs = new TextReader(resource).get();

TokenTextSplitter splitter =
        TokenTextSplitter.builder()
                .withChunkSize(chunkSize)
                .build();

List<Document> chunks =
        splitter.apply(docs);

문서를 그대로 저장하지 않고 여러 개의 Chunk로 분리하였다.

전체 과정은

TXT 파일

↓

TextReader

↓

Document 생성

↓

Chunk 분할

↓

Embedding

↓

Vector DB 저장

순서로 진행된다.


TextReader

new TextReader(resource)

TXT 파일을 읽어서 Document 객체를 생성한다.


TokenTextSplitter

문서를 일정한 크기로 분리하는 역할을 수행한다.

예를 들어

1000자의 문서라면

Chunk1

Chunk2

Chunk3

Chunk4

처럼 여러 개로 나눈다.


Chunk를 나누는 이유

LLM은 한 번에 처리할 수 있는 토큰 수가 제한되어 있다.

또한

문서를 너무 크게 저장하면

질문과 관계없는 내용까지 같이 검색된다.

반대로

너무 작게 나누면

문맥(Context)이 끊어질 수 있다.

따라서 적절한 Chunk Size를 설정하는 것이 검색 품질을 높이는 중요한 요소라는 점을 이해하였다.


UUID를 이용한 Document 관리

Chunk를 생성한 후

UUID를 이용하여 Document의 ID를 생성하였다.

.id(

UUID.nameUUIDFromBytes(

("sample.txt:" + c.getText())

.getBytes(StandardCharsets.UTF_8)

).toString()

)

UUID를 랜덤하게 생성하지 않고

문서 내용을 기반으로 생성하였다.

그 이유는

같은 문서를 여러 번 적재하더라도

같은 UUID가 생성되기 때문이다.

같은 문서

↓

같은 UUID

↓

기존 문서 덮어쓰기

가 가능해진다.

이를 통해 중복 데이터 저장을 방지하는 방법을 학습하였다.


QuestionAnswerAdvisor를 이용한 RAG 응답 생성

RAG에서 가장 핵심이 되는 기능은

QuestionAnswerAdvisor였다.

ChatResponse response =
        chatClient.prompt()

.advisors(

a -> a.param(

QuestionAnswerAdvisor.FILTER_EXPRESSION,

"category == '%s'".formatted(category)

)

)

Advisor는 질문을 AI에게 보내기 전에

자동으로 관련 문서를 검색한다.

이번 프로젝트에서는

category == spring

처럼 Metadata를 이용하여

특정 카테고리의 문서만 검색하도록 구현하였다.

사용자 질문

↓

Metadata Filter

↓

Vector Search

↓

관련 문서

↓

LLM

↓

답변 생성

과정이 자동으로 수행된다.


MainController의 전체 요청 흐름

Controller에서는 사용자의 요청을 받아

각 기능을 Service로 전달하였다.

@PostMapping("/chat")

public String chat(

@RequestParam String question,

@RequestParam String category,

RedirectAttributes redirectAttributes
)

사용자가 질문을 입력하면

JSP

↓

MainController

↓

DocumentService

↓

QuestionAnswerAdvisor

↓

VectorStore 검색

↓

LLM

↓

답변 생성

↓

JSP 출력

순서로 처리된다.

Controller는 검색이나 AI 호출을 직접 수행하지 않고

요청을 Service에 전달하는 역할만 담당하였다.

이를 통해 MVC 구조를 유지하면서도 RAG 기능을 자연스럽게 통합하는 방법을 학습하였다.


학습한 내용

학습 내용세부 학습 내용
Document텍스트와 Metadata를 함께 저장하는 객체
VectorStoreDocument 저장 시 자동으로 Embedding을 수행하는 구조
Similarity Search질문과 가장 의미가 가까운 문서를 검색하는 과정
SearchRequestQuery, Top-K, Similarity Threshold를 이용한 검색 조건 설정
Metadata카테고리와 출처 정보를 저장하여 검색 범위를 제한하는 방법
Ingest PipelineTextReader → Chunking → Embedding → Vector Database 저장 과정
TokenTextSplitter문서를 적절한 크기의 Chunk로 분리하는 방법
UUID문서 내용을 기반으로 고유 ID를 생성하여 중복 적재를 방지하는 방법
QuestionAnswerAdvisor검색된 문서를 Prompt에 자동으로 포함하여 RAG를 구현하는 방법
RAG Pipeline문서 저장과 검색, 생성형 AI를 하나의 흐름으로 연결하는 전체 구조

느낀 점

이번 프로젝트를 통해 RAG는 단순히 AI 모델의 성능을 높이는 기술이 아니라, 검색 시스템(Vector Database)과 생성형 AI를 하나의 파이프라인으로 연결하는 구조라는 점을 깊이 이해할 수 있었다. 특히 문서를 Document 객체로 생성하고, VectorStore가 자동으로 임베딩을 수행하여 저장하는 과정, Similarity Search를 통해 의미적으로 가장 유사한 문서를 검색하는 과정, QuestionAnswerAdvisor가 검색된 문서를 프롬프트에 자동으로 포함시키는 과정을 직접 구현하면서 RAG 시스템의 내부 동작 원리를 체계적으로 학습할 수 있었다. 또한 Chunk Size, Metadata, Top-K, Similarity Threshold와 같은 요소들이 검색 정확도와 AI 답변의 품질에 큰 영향을 미친다는 점을 실습을 통해 확인하면서, 실무에서 RAG를 구축할 때 검색 성능과 문서 관리 전략이 매우 중요하다는 사실을 배울 수 있었다.

0개의 댓글