반응형

사용사례

필자는 경력기술서를 업로드하여 아래 질문을 확인하는 용도로 개발

- 내 기술 경력서 요약

- 기술 경력서 내용 개선포인트 체크

- 면접관이 본다면 어떤 질문을 할까?

 

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] 1편: 토이 프로젝트 개요 

[Spring AI] 3편: Database 쿼리생성 및 실행을 위한 HITL(Human in the Loop)

[Spring AI] 4편: MCP 서버 구동과 실행

 

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

 

반응형
복사했습니다!