[Spring AI + Ollama + Rag 도입기] 자동화 창고 챗봇 서비스 구현 ③

양현지·2026년 5월 14일

연구

목록 보기
20/26

[Spring AI + Ollama + Rag 도입기] 자동화 창고 챗봇 서비스 구현 ② 에 이어 RAG 기반 자동화 창고 챗봇 서비스 구현을 이어가보고자 한다.

1. 모델 최적화

현재 모델은 llama3.1:1b 로, 응답 속도에 치중한 모델 (성능은 👎)
다만, 현재 GPU 서버를 쓸 수없으므로,, ollama 모델 중 최적을 찾아보도록 한다....

✔️ GPU 가속 지원 여부 ✔️
├── NVIDIA GPU → CUDA 지원 ✅ Docker에서 GPU 사용 가능
├── AMD GPU → ROCm 지원 ✅ 일부 가능
└── Intel Iris Xe(현재 필자의 환경*) → 미지원 ❌ CPU로만 동작

✔️ 클라우드 GPU 서버(대안)... ✔️

AWS EC2 g4dn.xlarge  → NVIDIA T4 GPU
월 비용: 약 $400 (온디맨드) / $150 (스팟)
Azure NC6s_v3
GCP n1-standard-4 + T4

현재까지의 아키텍처를 다시 정리해보자..

① Ollama — LLM 서버를 로컬에서 실행하는 플랫폼. llama3.2:1b는 답변 생성용, nomic-embed-text는 텍스트를 벡터로 변환하는 임베딩 모델. 두 모델이 같은 Ollama 서버에서 돌아간다.
② Chroma DB (Docker) — 벡터를 저장하고 검색하는 DB. PPTX에서 추출한 텍스트가 Ollama 임베딩 모델을 통해 숫자 벡터로 변환된 뒤 여기에 저장된다.
③ Spring AI 서버 — 두 가지 데이터 소스를 통합하는 핵심:

MSSQL → SQL 직접 조회 (재고 수량 등 실시간 정형 데이터)
Chroma → 벡터 검색 (PPTX 스키마 문서 등 비정형 데이터)
두 결과를 합쳐 Ollama LLM에 주입 → 자연어 답변 생성

계속 터미널로 실행할 수 없으니 index.html로 베타버전 프롬프트를 간단히 만들어보자. (추후에는 MAIN과 API로 연동될 예정이라 별도 UI/UX가 불필요하긴 함.)

간단한 질문부터 TRY 해보자.

  • 생각해보니 oracle DB 설계서를 넣고 다른 SITE의 랙정보를 물어보았다.

📍 application.yml

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // Spring AI 1.1.6 stable
    implementation 'org.springframework.ai:spring-ai-starter-model-ollama'

    // Chroma Vector Store
    implementation 'org.springframework.ai:spring-ai-starter-vector-store-chroma'

    // MSSQL JDBC Driver
    implementation 'com.microsoft.sqlserver:mssql-jdbc:12.6.3.jre11'

    // ❗ Oracle 드라이버 추가
    implementation 'com.oracle.database.jdbc:ojdbc11:23.4.0.24.05'

    // JPA
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // Lombok
    compileOnly 'org.projectlombok:lombok:1.18.38'
    annotationProcessor 'org.projectlombok:lombok:1.18.38'

    // Java 21 + ClosedChannelException 해결
    implementation 'com.squareup.okhttp3:okhttp:4.12.0'

    // PDF 읽기
    implementation 'org.springframework.ai:spring-ai-pdf-document-reader'

    // PPTX / 다양한 문서 형식 (Tika 기반)
    implementation 'org.springframework.ai:spring-ai-tika-document-reader'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

📍 application.yml

server:
  port: 8081
  servlet:
    encoding:
      charset: UTF-8
      enabled: true
      force: true

spring:
  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 100MB

  datasource:
    url: jdbc:oracle:thin:@//{HOST}:1521/{DBNAME}
    username: {USER}
    password: {PASSWORKD}
    driver-class-name: oracle.jdbc.OracleDriver
    hikari:
      maximum-pool-size: 10
      minimum-idle: 5
      connection-timeout: 30000

  jpa:
    hibernate:
      ddl-auto: none
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.OracleDialect
    open-in-view: false

  mvc:
    async:
      request-timeout: 300000

  ai:
    ollama:
      base-url: http://localhost:11434
      chat:
        options:
          model: llama3.2:3b
          temperature: 0.3
      embedding:
        options:
          model: nomic-embed-text

    vectorstore:
      chroma:
        client:
          base-url: http://localhost:8000
        collection-name: wms-rag
        initialize-schema: true
  • Oracle DB로 다시 시도해보자.

DB 설계서를 제대로 분석하지 못한듯 한 답변을 확인할 수 있다.

의문점
근데 내가 올린 pptx를 보긴하는거야 ? 만약 질문이 들어오면 벡터화된 ppt나 pdf도 보고 질문내용에 따라 db 조회도 하고 해야하는걸 llm이 알아서 해줘야하는거 아닌가?

  • 이게 쿼리 대상인지 vector db에서 검색해야하는건지. 둘다 써야하는건지 등등

질문 입력

[1단계] LLM이 라우팅 판단
├─ DB 조회 필요? → MSSQL 쿼리 실행
├─ 문서 검색 필요? → Chroma 벡터 검색
└─ 둘 다 필요? → 둘 다 실행

[2단계] 결과 합쳐서 최종 답변 생성

** 이러한 방향으로 가려면 ?

라우팅 판단도 LLM에 맡겨보도록 한다.

@Slf4j
@Service
@RequiredArgsConstructor
public class RagService {

    private final VectorStore vectorStore;
    private final ChatClient chatClient;
    private final JdbcTemplate jdbcTemplate;

    public String ask(String question) {

        // 1단계: LLM이 어떤 소스가 필요한지 판단
        String routing = routeQuestion(question);
        log.info("라우팅 결정: {}", routing);

        String dbContext = "";
        String vectorContext = "";

        // 2단계: 판단 결과에 따라 선택적으로 조회
        if (routing.contains("DB")) {
            dbContext = fetchDbContext(question);
            log.info("DB 조회 완료 ({}자)", dbContext.length());
        }

        if (routing.contains("DOC")) {
            vectorContext = fetchVectorContext(question);
            log.info("문서 검색 완료 ({}자)", vectorContext.length());
        }

        // 3단계: 최종 답변 생성
        return generateAnswer(question, dbContext, vectorContext);
    }

    // LLM이 라우팅 결정
    private String routeQuestion(String question) {
        String result = chatClient.prompt()
                .system("""
                        당신은 질문 분류기입니다.
                        질문을 보고 어떤 데이터 소스가 필요한지 판단하세요.
                        
                        데이터 소스:
                        - DB: 재고 수량, 입출고 현황, 로케이션, 아이템 등 실시간 데이터
                        - DOC: 업무 매뉴얼, SOP, 프로세스 설명, 시스템 설계 등 문서
                        
                        반드시 아래 형식 중 하나만 출력하세요:
                        DB
                        DOC
                        DB,DOC
                        """)
                .user(question)
                .call()
                .content();

        // 응답 정제
        if (result.contains("DB") && result.contains("DOC")) return "DB,DOC";
        if (result.contains("DB")) return "DB";
        if (result.contains("DOC")) return "DOC";
        return "DB,DOC"; // 판단 못하면 둘 다
    }

    // Chroma 벡터 검색
    private String fetchVectorContext(String question) {
        try {
            List<Document> docs = vectorStore.similaritySearch(
                    SearchRequest.builder()
                            .query(question)
                            .topK(5)
                            .build()
            );
            if (docs.isEmpty()) return "관련 문서 없음";
            return docs.stream()
                    .map(Document::getText)
                    .collect(Collectors.joining("\n---\n"));
        } catch (Exception e) {
            log.error("벡터 검색 오류: {}", e.getMessage());
            return "";
        }
    }

    // DB 직접 조회
    private String fetchDbContext(String question) {
        try {
            // LLM이 SQL 생성
            String vectorContext = fetchVectorContext("테이블 스키마 " + question);
            String sql = generateSql(question, vectorContext);
            log.info("생성된 SQL: {}", sql);
            return executeQuery(sql);
        } catch (Exception e) {
            log.error("DB 조회 오류: {}", e.getMessage());
            return "DB 조회 오류: " + e.getMessage();
        }
    }

    // LLM이 SQL 생성
    private String generateSql(String question, String schemaContext) {
        String sql = chatClient.prompt()
                .system("""
                        당신은 Oracle DB 전문가입니다.
                        아래 [스키마 정보]를 참고하여 Oracle SQL을 생성하세요.
                        
                        규칙:
                        - SELECT 문만 생성
                        - SQL 코드만 출력 (설명, 마크다운 없이)
                        - FETCH FIRST 20 ROWS ONLY 로 결과 제한
                        - 스키마명 없이 테이블명만 사용 (CURRENT_SCHEMA 설정됨)
                        """)
                .user("""
                        [스키마 정보]
                        %s
                        
                        [질문]
                        %s
                        
                        SQL:
                        """.formatted(schemaContext, question))
                .call()
                .content();

        return sql.replaceAll("```sql", "")
                  .replaceAll("```", "")
                  .trim();
    }

    // SQL 실행
    private String executeQuery(String sql) {
        try {
            if (!sql.trim().toUpperCase().startsWith("SELECT")) {
                return "SELECT 쿼리만 허용됩니다.";
            }
            List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
            if (rows.isEmpty()) return "조회 결과 없음";
            return rows.stream()
                    .map(row -> row.entrySet().stream()
                            .map(e -> e.getKey() + "=" + e.getValue())
                            .collect(Collectors.joining(", ")))
                    .collect(Collectors.joining("\n"));
        } catch (Exception e) {
            log.error("SQL 실행 오류: {} / SQL: {}", e.getMessage(), sql);
            return "SQL 실행 오류: " + e.getMessage();
        }
    }

    // 최종 답변 생성
    private String generateAnswer(String question, String dbContext, String vectorContext) {
        StringBuilder context = new StringBuilder();
        if (!dbContext.isEmpty()) {
            context.append("[실시간 DB 데이터]\n").append(dbContext).append("\n\n");
        }
        if (!vectorContext.isEmpty()) {
            context.append("[문서 데이터]\n").append(vectorContext);
        }

        return chatClient.prompt()
                .system("""
                        당신은 자동화창고(WCS) 전문가 어시스턴트입니다.
                        제공된 데이터를 기반으로 질문에 답변하세요.
                        데이터에 없는 내용은 '해당 정보가 없습니다'라고 답하세요.
                        답변은 한국어로 작성하세요.
                        """)
                .user("""
                        %s
                        
                        [질문]
                        %s
                        """.formatted(context.toString(), question))
                .call()
                .content();
    }
}

다시 드는 의문점
이렇게까지 라우팅 지정 prompt도 작성해줘야하나 ? 원래 LLM이 알아서 해주지 않나 ?
RAG이라는게 나는 데이터만 넣어주고 이 데이터를 벡터화해서 chromadb에 올린다

  • ollama 기반(폐쇄망이므로) 로컬 모델에서 알아서 다 처리해야하지않나
위 방향대로 가려면 — 클라우드 LLM

GPT-4o, Claude 3.5 같은 고성능 모델
→ 문맥 파악 능력이 뛰어남
→ "이건 DB 조회해야겠다" 스스로 판단
→ Tool Calling 기능으로 DB/벡터 자동 선택
→ 프롬프트 거의 안 써도 됨

BUT,, 로컬 소형 모델의 경우

llama3.2:1b, 3b 같은 소형 모델
→ 문맥 파악 능력이 제한적
→ 지시를 명확히 줘야 따름
→ Tool Calling 지원 불안정
→ 프롬프트 없으면 엉뚱한 답변

llama3.2:1b는 GPT-4o 대비 성능이 100배 이상 차이난다. 그래서 라우팅 프롬프트를 명시해줘야 한다.
llama3.2:8b까지 올려보고 테스트 해본다. (그나마 ollama 로컬 모델 중 성능이 좋은편)
-> 역시나 처리속도가 미진하다.

3가지 비교해보자

ollama run qwen3:8b
ollama run qwen3:14b


➡️ 일단 응답 불가한 수준

ollama run mistral:7b
ollama run deepseek-coder:6.7b

그러니까 지금 상태가,, ollama로 로컬 LLM 올리고, 이 모델을 rag에 연동하고, RAG 대상 데이터는 chromadb로 벡터화 해둔거고

프롬프트 요청을 처리하기까지 프로세스는 되어있는데 모델 성능이 처참하다...

2. OPEN AI API 도입 (임시*)

RAG 파이프라인을 정말 제대로 경험해보기 위해 그래도 쓸만한 모델 도입의 필요성을 확인하였다. 임시로라도 OPEN AI API KEY를 발급받아 사용해보자

2-1. API KEY 생성

https://console.groq.com/keys

2-2. build.gradle — OpenAI starter 추가 (Ollama 유지)

$env:GROQAPI_KEY = "gsk...KJYK"

2-3. Chroma DB 초기화

이전에 올리 MsSQL 데이터를 계속 포함시킨다.

docker stop chromadb
dockr rm chromadb
Remove-Item -Recurse -Force C:\Project\wms-rag-service\chroma-data
## 재실행
docker run -d `
  --name chromadb `
  -p 8000:8000 `
  -v C:\Project\wms-rag-service\chroma-data:/chroma/chroma `
  chromadb/chroma


드디어 유의미한 응답을 생성한다.
BUT,, 고성능 추론으로 넘어가면서부터 TOKEN 제한에 걸린다. (무료 플랜의 한계 !)


WCS 시스템 매뉴얼 을 넣어보았다.
거의 매뉴얼 내용 복붙 수준으로 응답하기는 한다.

꽤나 ,, 답답하지만 이전보다는 대화가 되는 수준이다.

순수 RAG → 문서 검색 + LLM 답변 (단순)
지금 구조 → 문서 검색 + SQL 생성 + DB 조회 + LLM 답변 (복잡)

  • DB 실시간 조회를 붙이면서 복잡도가 올라갔다.

GitHub. wms-rag-service

0개의 댓글