사용사례
필자는 경력기술서를 업로드하여 아래 질문을 확인하는 용도로 개발
- 내 기술 경력서 요약
- 기술 경력서 내용 개선포인트 체크
- 면접관이 본다면 어떤 질문을 할까?
RAG(Retrieval-Augmented Generation)
레그를 적재하고 검색할 수 있도록 해보자.
적재
RAG 적재에는 아래의 흐름을 가진다.
- Document Loader -> Text Splitter -> Embedding -> VectorDB
우리는 수많은 Chunk 로 잘려진 데이터를 임베딩하여 VectorDB에 적재해야한다.
VectorDB는 다양한데 필자는 Postgresql의 vectorDB plugin을 활용해보았다.
Cloud Navtive VectorDB
| 종류 | 특징 | 추천 상황 |
| Pinecone | 완전 관리형(SaaS), 설정이 거의 필요 없음. | 인프라 관리 없이 빠르게 MVP를 출시하고 싶을 때. |
| Milvus | 오픈소스 기반, 매우 높은 확장성과 성능. | 대규모 트래픽과 대용량 데이터를 다루는 엔터프라이즈 급. |
| Weaviate | 키워드 검색(BM25)과 벡터 검색을 합친 하이브리드 검색 강점. | 객체 지향적인 데이터 구조와 하이브리드 검색이 중요할 때. |
| Chroma | 가볍고 사용이 간편함 (In-memory 지원). | 로컬 개발 환경이나 소규모 프로젝트 테스트용. |
설치형 VectorDB
- pgvector (PostgreSQL): 풀스택 개발자들에게 가장 인기가 많습니다. 기존 SQL 쿼리와 벡터 검색을 JOIN해서 쓸 수 있습니다.
- Elasticsearch / OpenSearch: 이미 검색 엔진으로 쓰고 있다면 최고의 선택입니다. 텍스트 검색과 벡터 검색을 버무리기 좋습니다.
- Redis (RedisVL): 실시간성이 극도로 중요할 때 사용합니다. 캐시 레이어에서 바로 벡터 검색을 수행합니다.
PGVector 셋팅
docker-compose 기동
version: '3.8'
services:
db:
image: pgvector/pgvector:pg16 # pgvector가 내장된 Postgres 16 이미지
container_name: pgvector_db
ports:
- "5432:5432"
environment:
- POSTGRES_USER=myuser
- POSTGRES_PASSWORD=mypassword
- POSTGRES_DB=aidb
volumes:
- ./postgres_data:/var/lib/postgresql/data
vector 확장자 실행 명령어
-- db 접속
docker exec -it pgvector_db psql -U myuser -d aidb
-- DB 접속 후 실행
CREATE EXTENSION vector;
-- 설치 확인 (버전이 나오면 성공!)
SELECT * FROM pg_extension WHERE extname = 'vector';
임베딩(Embadding)
처음에 로컬로 모두 구현하려고 했으나 노트북이 너무 느린관계로 제미나이로 변경했다.
참고로 적재할 때 임베딩모델과 검색할 때 모델은 동일해야한다.
올라마로 임베딩 모델 all-minilm 을 쓰면 384 차원 으로 임베딩했고,
제미나이 임베딩 모델 gemini-embedding-001 을 쓰면 최대 3072 차원까지 지원했다.
하지만 pgvector 에서 2000 차원까지 지원한다고 하기에 설정을 조정(768 차원).
변경하면서 테스트 했기 때문에 차원정보가 다르다는 에러를 마주했었고 그런 경우 해당 벡터테이블의 데이터를 지우면된다
- ERROR: different vector dimensions 768 and 3072
-- 1. 기존 데이터 전부 삭제 (차원이 다르면 검색이 안 되므로 어차피 지워야 함)
DELETE FROM vector_store;
-- 2. 이제 타입 변경 가능!
ALTER TABLE vector_store ALTER COLUMN embedding TYPE vector(768);
코드
spring:
ai:
vectorstore:
pgvector:
# index-type: HNSW # 대규모 검색에 효율적인 인덱스 방식
dimension: 768 # Gemini Embedding 모델의 기본 차원 (모델에 따라 확인 필요)
collection-name: spring_pdf_rag
# ollama:
# base-url: http://localhost:11434
# embedding:
# options:
# model: all-minilm
google:
genai:
# spring AI 1.1.2>채팅모델>Google GenAI 규격인데 동작안함
api-key: ${GOOGLE_API_KEY:missing_key}
chat:
options:
model: gemini-3.1-flash-lite-preview
# spring AI 1.1.2>임베딩모델>Google GenAI 규격인데 동작안함
embedding:
api-key: ${GOOGLE_API_KEY:missing_key}
text:
options:
model: gemini-embedding-001 # 3072 지원함
task-type: RETRIEVAL_DOCUMENT # 문서 저장 시
spring AI의 설정을 spring docs를 보고 하는데 잘 안되서.....직접 bean 생성함.
ai:
api-key: ${GOOGLE_API_KEY:missing_key}
model: gemini-3.1-flash-lite-preview
default-system: "너는 20년 경력의 시니어 개발자야. 친절하고 간결하게 대답해줘."
embed-model: gemini-embedding-001
prompts:
intent-route: classpath:prompts/intent-route.st
general: classpath:prompts/general.st
rag: classpath:prompts/rag.st
시스템프롬프트를 .st 형식으로 분류 함
@Getter
@Setter
@ConfigurationProperties(prefix = "ai")
public class AiProperties {
private String apiKey;
private String model = "gemini-1.5-flash"; // 기본값 설정
private String defaultSystem;
// 중첩 클래스로 프롬프트 관리
private final Prompts prompts = new Prompts();
@Getter
@Setter
public static class Prompts {
private Resource intentRoute;
private Resource general;
private Resource rag;
}
}
프로퍼티 값을 읽어 오고
@Primary
@Bean
public ChatClient chatClientSimple(GoogleGenAiChatModel chatModel, CustomRedisChatMemoryRepository chatMemoryRepository) {
return ChatClient.builder(chatModel)
.defaultSystem(aiProperties.getDefaultSystem())
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(
MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository)
.maxMessages(20)
.build()
)
.conversationId("default")
.order(1)
.build()
)
.build();
}
// VectorDB 연결 클라이언트
@Bean
public ChatClient chatClientVector(GoogleGenAiChatModel chatModel, VectorStore vectorStore, CustomRedisChatMemoryRepository chatMemoryRepository) {
return ChatClient.builder(chatModel)
.defaultSystem(aiProperties.getDefaultSystem())
.defaultAdvisors(QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.topK(3).build())
.build(),
MessageChatMemoryAdvisor.builder(
MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository)
.maxMessages(20)
.build()
)
.conversationId("default")
.order(1)
.build()
)
.build();
}
@Bean
@Primary
public GoogleGenAiChatModel googleGenAiChatModel(
Client googleGenAiClient,
ObjectProvider<ToolCallingManager> toolCallingManager,
ObjectProvider<RetryTemplate> retryTemplate,
ObjectProvider<ObservationRegistry> observationRegistry) {
// 모델 옵션 설정
GoogleGenAiChatOptions options = GoogleGenAiChatOptions.builder()
.model(aiProperties.getModel())
.temperature(0.7) // 창의성 조절 (0.0 ~ 2.0)
.build();
// 모델 생성
return new GoogleGenAiChatModel(
googleGenAiClient,
options,
toolCallingManager.getIfAvailable(() -> null), // Tool 호출 관리
retryTemplate.getIfAvailable(RetryTemplate::new), // 재시도 로직
observationRegistry.getIfAvailable(() -> ObservationRegistry.NOOP) // 모니터링
);
}
@Bean
public EmbeddingModel embeddingModel() {
// 1. 연결 정보 설정 (API Key 방식)
GoogleGenAiEmbeddingConnectionDetails connectionDetails =
GoogleGenAiEmbeddingConnectionDetails.builder()
.apiKey(aiProperties.getApiKey())
.build();
// 2. 옵션 설정 (TaskType 설정이 중요합니다)
GoogleGenAiTextEmbeddingOptions options = GoogleGenAiTextEmbeddingOptions.builder()
.model("gemini-embedding-001") // 공식 추천 모델
// RAG용 문서 인덱싱이라면 RETRIEVAL_DOCUMENT가 정석입니다.
.taskType(GoogleGenAiTextEmbeddingOptions.TaskType.RETRIEVAL_DOCUMENT)
.dimensions(768) // default 3072나 postgresql 이 2000까지만 지원함
.build();
// 3. 모델 객체 생성 및 반환
return new GoogleGenAiTextEmbeddingModel(connectionDetails, options);
}
챗모델과 임베딩 모델을 구글제미니로 설정한 뒤
백터DB가 없는 챗클라이언트와 백터용챗클라이언트를 생성해주었다.
추가로 레디스를 채팅기억장치로 쓰기 위해 CustomRedisChatMemoryRepository.java 를 생성하여 연결함
// 비동기 방식 webflux
public Mono<Void> ingestPdfStream(FilePart filePart) {
// 1. FilePart의 내용을 Resource로 변환 (비동기 처리)
return DataBufferUtils.join(filePart.content())
.map(dataBuffer -> {
// TikaReader는 InputStream이 필요하므로 버퍼에서 읽어옵니다.
return new InputStreamResource(dataBuffer.asInputStream(true));
})
.publishOn(Schedulers.boundedElastic()) // 블로킹 작업(I/O, 임베딩)을 위한 스레드 전환
.doOnNext(resource -> {
// 2. PDF 읽기 및 분할
TikaDocumentReader reader = new TikaDocumentReader(resource);
List<Document> documents = reader.get();
TokenTextSplitter splitter = new TokenTextSplitter(800, 100, 5, 10000, true);
List<Document> splitDocuments = splitter.apply(documents);
// 3. 벡터 DB 저장 (Gemini 임베딩 발생 지점)
vectorStore.accept(splitDocuments);
System.out.println("✅ Gemini 적재 완료: " + splitDocuments.size() + " 청크.");
})
.then(); // 결과값 없이 완료 신호만 보냄
}
그리고 파일을 처리할 수 있는 서비스를 생성했고, 컨트롤러에서 연결시켜주면 적재를 테스트할 수 있다.
http://localhost:8080/rag 에 접근해 확인할 수 있음

검색
클라이언트에게 만족감을 주기 위해 비동기 방식으로 데이터를 전달! (Webflux)
public Flux<String> askStream(String chatId, String message) {
// 1. 유사 문서 검색 (Blocking 작업을 Flux 흐름으로 변환)
return Mono.fromCallable(() -> {
SearchRequest searchRequest = SearchRequest.builder()
.topK(4)
.query(message)
.build();
return vectorStore.similaritySearch(searchRequest);
})
.subscribeOn(Schedulers.boundedElastic()) // 검색 작업은 워커 쓰레드에서!
.flatMapMany(docs -> {
// 2. 컨텍스트 조립
String context = docs.stream()
.map(Document::getText)
.collect(Collectors.joining("\n\n"));
return chatClient.prompt()
.advisors(advisor -> advisor.param("chat_memory_conversation_id", chatId))
.system(sp -> sp.text(prompts.getPrompts().getRag()).param("context", context))
.user(message)
.stream()
.content();
});
}
vectorStore 객체를 가져와 유사도 검색을 하면 된다.
+실제 운영에서는 하이브리드하게 유사도+단어 기반으로 검색을 구현해야 하는 경우가 생긴다고 함
Apache Tika
문서를 파싱하는데 어떤 라이브러리를 썼냐면
아파치 티카라고 다양한 문서포맷을 텍스트로 추출해주는 오픈소스가 있는데
spring 에서 티카를 채택하여 제공하고 있다.
implementation 'org.springframework.ai:spring-ai-tika-document-reader'
문서 파싱의 아쉬움
작년에 프로젝트를 수행하며 타 AI 업체에서 아파치 티카를 사용한 사례가 있었는데,
apache tika의 장점은 지원하는 문서포맷이 많고, 텍스트로 추출해주는 것이다.
하지만 단점은 문서에 따라 하드하게 추출할 수 없는 점이였다.
(이미지 추출, 페이지 분류, 이미지 검색 등...)
이 경우 python 서버를 따로 구축해서 사용하길 권함.
물론 문서별로 파싱하는 것과 템플릿을 기준으로 딥하게 케이스를 나누면 아주 좋겠으나 힘들듯.
그리고 파이선진영에 다양한 라이브러리가 존재하니 검색해보면 좋음.
딥한 PDF 파싱 시 생각해 볼 점
아래는 유튜브에서 1년간 AI 프로젝트 사례를 보고 정리한 내용으로
PDF파일을 파싱하려면 어떤 고민 Point가 있는지 점검할 수 있었다.
1. 페이지 단위 분할이 되는가?
ex) 출처를 알아내기 위해 필요함.
2. 메타데이터 정보가 태깅되는가?
ex) 해당 문서의 몇페이지에서 발견한 내용인지, 파일명, 수정일, 작성자는 누구.
3. 영역을 Crop 할 수 있는가?
ex) 머리말이나 꼬리말로 이상한 검색 결과가 나올 수 있어 파싱영역을 지정.
4. 페이지 분할 형태를 읽을 수 있는가?
ex) 뉴스페이퍼처럼 분할되어있는 문서를 보고 영역별로 파싱할 수 있어야함
5. 표(테이블)을 추출 할 수 있는가?
ex) 정형화된 데이터로 추출하며, 메타데이터를 추출해야 함.
6. 이미지를 추출할 수 있는가?
ex) 커머스의 상품 이미지, 안전수칙, 그래프/차트 이미지와 같은 것을 데이터화 할 수 있어야 함.
7. 페이지가 넘어가며 맥락이 유지되는가?
ex) 유사도 청크를 체크 해야함, 청크오버랩(Chunk Overlab)
[Spring AI] 3편: Database 쿼리생성 및 실행을 위한 HITL(Human in the Loop)
GitHub - joonhyeok95/spring-ai-google-gen: 제미나이 무료API로 구현하는 PDF RAG, 데이터베이스 추출, LLM 서비
제미나이 무료API로 구현하는 PDF RAG, 데이터베이스 추출, LLM 서비스. Contribute to joonhyeok95/spring-ai-google-gen development by creating an account on GitHub.
github.com
'개발 > Spring' 카테고리의 다른 글
| [Spring AI] 4편: MCP 서버 구동과 실행 (0) | 2026.03.20 |
|---|---|
| [Spring AI] 3편: Database 쿼리생성 및 실행을 위한 HITL(Human in the Loop) (0) | 2026.03.20 |
| [Spring AI] 1편: 토이 프로젝트 개요 (0) | 2026.03.20 |
| [Springboot] @JsonProperty는 언제 써야 할까! (0) | 2023.02.22 |
| [MSA] SAGA pattern (0) | 2022.12.19 |