[Spring AI + Ollama + Rag 도입기] 자동화 창고 챗봇 서비스 구현 ② 에 이어 RAG 기반 자동화 창고 챗봇 서비스 구현을 이어가보고자 한다.
현재 모델은 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가 불필요하긴 함.)


📍 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

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 기반(폐쇄망이므로) 로컬 모델에서 알아서 다 처리해야하지않나
GPT-4o, Claude 3.5 같은 고성능 모델
→ 문맥 파악 능력이 뛰어남
→ "이건 DB 조회해야겠다" 스스로 판단
→ Tool Calling 기능으로 DB/벡터 자동 선택
→ 프롬프트 거의 안 써도 됨
llama3.2:1b, 3b 같은 소형 모델
→ 문맥 파악 능력이 제한적
→ 지시를 명확히 줘야 따름
→ Tool Calling 지원 불안정
→ 프롬프트 없으면 엉뚱한 답변
llama3.2:1b는 GPT-4o 대비 성능이 100배 이상 차이난다. 그래서 라우팅 프롬프트를 명시해줘야 한다.
llama3.2:8b까지 올려보고 테스트 해본다. (그나마 ollama 로컬 모델 중 성능이 좋은편)
-> 역시나 처리속도가 미진하다.
ollama run qwen3:8b
ollama run qwen3:14b


➡️ 일단 응답 불가한 수준
ollama run mistral:7b
ollama run deepseek-coder:6.7b
그러니까 지금 상태가,, ollama로 로컬 LLM 올리고, 이 모델을 rag에 연동하고, RAG 대상 데이터는 chromadb로 벡터화 해둔거고
프롬프트 요청을 처리하기까지 프로세스는 되어있는데 모델 성능이 처참하다...
$env:GROQAPI_KEY = "gsk...KJYK"
이전에 올리 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 실시간 조회를 붙이면서 복잡도가 올라갔다.