[Spring AI] Spring AI로 RAG 구현하기 — pgvector, RetrievalAugmentationAdvisor, VectorStore
by 우와한개발자
1. 전체 구성
1) 사용 기술
- Spring AI : RAG 구현을 위한 프레임워크
- OpenAI : 임베딩 모델(text-embedding-3-small), Chat 모델(gpt-4o-mini)
- Ollama : 로컬 Chat 모델(llama3.1:8b)
- pgvector : Vector DB
2) 전체 흐름
[사전 준비]
문서 텍스트
→ OpenAI 임베딩 모델이 벡터로 변환
→ pgvector에 저장
[질문 시]
사용자 질문
→ 임베딩 모델이 벡터로 변환
→ pgvector에서 코사인 유사도로 유사한 문서 검색
→ 임계치 이상 문서를 프롬프트 context에 삽입
→ LLM이 context를 참고해서 답변 생성
2. 의존성 및 설정
1) build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-starter-model-ollama'
implementation 'org.springframework.ai:spring-ai-starter-vector-store-pgvector'
implementation 'org.springframework.ai:spring-ai-rag'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
}
2) application.properties
# OpenAI
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.model=gpt-4o-mini
spring.ai.openai.embedding.model=text-embedding-3-small
# Ollama
spring.ai.ollama.base-url=http://localhost:11434
spring.ai.ollama.chat.options.model=llama3.1:8b
spring.ai.ollama.chat.options.temperature=0.2
# pgvector
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.ai.vectorstore.pgvector.index-type=hnsw
spring.ai.vectorstore.pgvector.distance-type=cosine-distance
spring.ai.vectorstore.pgvector.dimensions=1536
spring.ai.vectorstore.pgvector.metadata-fields=type,docId,topic
# ollama embedding 비활성화 (pgvector는 OpenAI embedding 사용)
spring.ai.ollama.embedding.enabled=false
- ollama와 openai를 함께 쓸 경우 EmbeddingModel Bean이 두 개 등록되어 충돌이 발생한다
- pgvector는 OpenAI EmbeddingModel을 사용하도록 ollama embedding을 비활성화해야 한다
3. ChatClient 설정—ChatConfig.java
@Configuration
public class ChatConfig {
@Bean
ChatMemory chatMemory() {
return MessageWindowChatMemory.builder()
.maxMessages(20).build();
}
@Bean("openAiChatClient")
@Primary
ChatClient openAiChatClient(OpenAiChatModel chatModel,
ChatMemory chatMemory, WeatherTool weatherTool) {
return ChatClient.builder(chatModel)
.defaultAdvisors(MessageChatMemoryAdvisor
.builder(chatMemory).build())
.defaultTools(weatherTool)
.build();
}
@Bean("ollamaChatClient")
ChatClient ollamaChatClient(OllamaChatModel chatModel,
ChatMemory chatMemory, WeatherTool weatherTool) {
return ChatClient.builder(chatModel)
.defaultAdvisors(MessageChatMemoryAdvisor
.builder(chatMemory).build())
.defaultTools(weatherTool)
.build();
}
// ollama와 openai EmbeddingModel 충돌 방지
@Bean
@Primary
public EmbeddingModel embeddingModel(OpenAiEmbeddingModel embeddingModel) {
return embeddingModel;
}
}
- @RequiredArgsConstructor(Lombok)와 @Qualifier를 함께 쓰면 @Qualifier가 무시된다
- 생성자를 직접 작성해서 @Qualifier를 명시해야 한다
4. 문서 저장 — VectorStoreBootstrap.java
@Component
@RequiredArgsConstructor
public class VectorStoreBootstrap {
private static final String INIT_MARKER_TEXT = "__VECTORSTORE_V1";
private final VectorStore vectorStore;
@PostConstruct
public void bootstrap() {
// 이미 초기화된 경우 스킵
if (isAlreadyBootstrapped(vectorStore)) return;
List<Document> documents = List.of(
new Document(INIT_MARKER_TEXT,
Map.of("type", "bootstrap", "version", "v1", "app", "myapp")),
new Document("Spring AI rocks!!",
Map.of("docId", "seed-1", "topic", "spring-ai")),
new Document("세상은 크고 구원은 세상의 모퉁이에 있습니다.",
Map.of("docId", "seed-2", "type", "quote")),
new Document("과거를 향해 앞으로 걸어가고 미래를 향해 되돌아갑니다.",
Map.of("docId", "seed-3", "type", "quote", "theme", "time"))
);
vectorStore.add(documents);
}
boolean isAlreadyBootstrapped(VectorStore vectorStore) {
var results = vectorStore.similaritySearch(
SearchRequest.builder()
.query(INIT_MARKER_TEXT)
.topK(1)
.filterExpression("type == 'bootstrap' && app == 'myapp'")
.build());
return results != null && !results.isEmpty();
}
}
- @PostConstruct : 앱 시작 시 자동 실행
- isAlreadyBootstrapped : 중복 저장 방지. 이미 초기화 마커가 있으면 스킵
- vectorStore.add() : Spring AI가 자동으로 임베딩 변환 후 pgvector에 저장
- metadata : filterExpression으로 필터링할 때 사용. metadata-fields에 등록된 필드만 필터링 가능
5. RAG 구현 — ChatService
1) RAG Advisor 초기화
private static final double SIM_THRESHOLD = 0.35;
private static final int TOP_K = 1;
// 사용자 질문을 벡터로 변환 후 VectorStore에서 유사한 문서를 찾아오는 검색기
this.retriever = VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(SIM_THRESHOLD)
.topK(TOP_K)
.build();
// 검색된 문서를 프롬프트 context에 자동으로 삽입해주는 RAG Advisor
this.ragAdvisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(this.retriever)
.build();
- SIM_THRESHOLD : 유사도 임계치. 이 값 이상인 문서만 검색 결과로 반환된다
- TOP_K : 최대 몇 개의 문서를 가져올지. 1로 설정하면 가장 유사한 문서 1개만 반환
- VectorStoreDocumentRetriever : 질문을 벡터로 변환 후 VectorStore에서 유사한 문서를 찾아오는 검색기
- RetrievalAugmentationAdvisor : 검색된 문서를 프롬프트 context에 자동으로 삽입해주는 Advisor
2) 유사도 검색 — hasRelevantDocs
private boolean hasRelevantDocs(String userInput) {
var results = vectorStore.similaritySearch(
SearchRequest.builder()
.query(userInput)
.topK(TOP_K)
.filterExpression("type == 'quote'") // 메타데이터 필터
.similarityThreshold(SIM_THRESHOLD)
.build()
);
// 유사도 점수 로깅
results.forEach(doc ->
System.out.println("score: " + doc.getScore() + " | " + doc.getText())
);
return results != null && !results.isEmpty();
}
- similaritySearch : 질문을 벡터로 변환 후 VectorStore에서 유사한 문서를 검색
- filterExpression : SQL WHERE절과 같은 역할. metadata-fields에 등록된 필드만 사용 가능
- doc.getScore() : 유사도 점수 확인. 임계치 조정이 필요할 때 활용
- RAG Advisor에도 threshold가 있지만 hasRelevantDocs에서 먼저 확인해 관련 문서가 없으면 불필요한 RAG 적용을 막는다
3) RAG 적용 — chatMemoryStream
public Flux<String> chatMemoryStream(String userInput, String userId) {
boolean useRag = hasRelevantDocs(userInput);
var prompt = ollamaChatClient.prompt()
.user(userInput)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userId));
if (useRag) {
// advisors()는 새 객체를 반환하므로 반드시 재할당해야 함
prompt = prompt.advisors(ragAdvisor);
}
return prompt.stream()
.content()
.map(this::preserveSseLeadingSpace)
.concatWith(Flux.just("[[END]]"));
}
- hasRelevantDocs로 관련 문서 존재 여부를 먼저 확인
- 관련 문서가 있을 때만 ragAdvisor를 추가해 context 기반 답변 생성
- advisors()는 새 객체를 반환하기 때문에 반드시 재할당해야 적용된다
- concatWith(Flux.just("[[END]]")) : 스트리밍 종료 시그널을 클라이언트에 전달
전체 코드
더보기
@Service
public class ChatService {
private final ChatClient openAiChatClient;
private final ChatClient ollamaChatClient;
private final VectorStore vectorStore;
private final VectorStoreDocumentRetriever retriever;
private final RetrievalAugmentationAdvisor ragAdvisor;
private static final double SIM_THRESHOLD = 0.35;
private static final int TOP_K = 1;
public ChatService(
@Qualifier("openAiChatClient") ChatClient openAiChatClient,
@Qualifier("ollamaChatClient") ChatClient ollamaChatClient,
VectorStore vectorStore) {
this.openAiChatClient = openAiChatClient;
this.ollamaChatClient = ollamaChatClient;
this.vectorStore = vectorStore;
this.retriever = VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(SIM_THRESHOLD)
.topK(TOP_K)
.build();
this.ragAdvisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(this.retriever)
.build();
}
public Mono<String> chatMemory(String userInput, String userId) {
return Mono.fromCallable(() -> ollamaChatClient.prompt()
.user(userInput)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userId))
.call()
.content());
}
public Flux<String> chatMemoryStream(String userInput, String userId) {
boolean useRag = hasRelevantDocs(userInput);
var prompt = ollamaChatClient.prompt()
.user(userInput)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userId));
if (useRag) {
prompt = prompt.advisors(ragAdvisor);
}
return prompt.stream()
.content()
.map(this::preserveSseLeadingSpace)
.concatWith(Flux.just("[[END]]"));
}
private boolean hasRelevantDocs(String userInput) {
var results = vectorStore.similaritySearch(
SearchRequest.builder()
.query(userInput)
.topK(TOP_K)
.filterExpression("type == 'poem'")
.similarityThreshold(SIM_THRESHOLD)
.build()
);
results.forEach(doc ->
System.out.println("score: " + doc.getScore() + " | " + doc.getText())
);
return results != null && !results.isEmpty();
}
private String preserveSseLeadingSpace(String chunk) {
if (chunk == null) return "";
return chunk.startsWith(" ") ? " " + chunk : chunk;
}
}
6. 주의사항
1) metadata-fields 설정과 테이블 재생성
- filterExpression은 유사도 검색 시 메타데이터 조건으로 검색 범위를 좁히는 필터로, SQL WHERE절과 같은 역할을 한다
- pgvector는 필터링을 위해 해당 필드를 별도 컬럼으로 인덱싱하기 때문에 metadata-fields에 사용할 필드를 미리 등록해야 한다
- 등록하지 않으면 filterExpression 자체가 동작하지 않는다
spring.ai.vectorstore.pgvector.metadata-fields=type,docId,topic,author,theme
- 설정 추가 후 vector_store 테이블을 DROP하고 재시작해야 반영된다
DROP TABLE vector_store;
2) 임계치(threshold) 튜닝
- 임계치가 너무 높으면 관련 문서가 있어도 검색되지 않는다
- 임계치가 너무 낮으면 관련 없는 문서가 context에 포함되어 답변 품질이 떨어진다
- doc.getScore()로 실제 유사도 점수를 확인하면서 조정하는 것이 좋다
3) 토큰 비용
- 검색된 문서가 프롬프트에 포함되기 때문에 문서 길이만큼 토큰이 추가로 소비된다
- OpenAI 같은 서드파티 API 사용 시 topK와 문서 크기 설정에 주의해야 한다
'AI > Spring AI' 카테고리의 다른 글
| [Spring AI] Spring AI 로 MCP Server 구현하기— @McpTool,@McpToolParam (0) | 2026.06.07 |
|---|---|
| [Spring AI] MCP(Model Context Protocol)란? 구조와JSON-RPC 2.0 통신 (0) | 2026.06.07 |
| [Spring AI] LLM 발전의 흐름 — Fine-tuning, Prompt Engineering, RAG, Tool Calling, MCP (0) | 2026.06.04 |
| [Spring AI] Tool Calling이란? — 함수 호출, JSON 스펙, @Tool, @ToolParam (0) | 2026.06.04 |
| [Spring AI] RAG(검색 증강 생성)란? — 외부 문서 기반 답변 (0) | 2026.06.04 |
블로그의 정보
우와한개발자 님의 블로그
우와한개발자