[Dev-Fortune] 시리즈 다시보기
- 1편: 기획부터 스택 선정까지
- 2편: 로컬 LLM Ollama 연동
- 3편: RAG와 Vector Store 구축
- 4편: 프롬프트 엔지니어링 실전
- 5편: 스트리밍 API 구현
- 6편: 전체 워크플로우 분석
- 7편: 프로젝트 회고와 로드맵
1. 서론: AI는 어떻게 사주를 ‘공부’하는가?
사전 학습된 거대 언어 모델(LLM)은 일반적인 상식이나 범용 프로그래밍 지식에는 능통하지만, 우리가 기획한 **“사주팔자를 소프트웨어 안티패턴으로 치환한 독자적인 해석 규칙”**은 전혀 알지 못합니다.
AI에게 특정 도메인의 전문 지식을 가르치는 방법은 크게 두 가지가 있습니다:
- 파인 튜닝(Fine-Tuning): 수천 건의 질의응답 데이터셋으로 모델 가중치를 직접 재학습시키는 방식 (비용이 매우 크고 시간 소요가 큼).
- RAG (Retrieval-Augmented Generation, 검색 증강 생성): 외부 지식 문서를 미리 벡터 저장소에 넣어두고, 사용자가 질문할 때 관련 문서를 실시간으로 검색하여 프롬프트 컨텍스트에 덧붙여주는 방식 (빠르고 경제적이며 언제든 데이터 갱신 가능).
‘Dev-Fortune’ 프로젝트는 데이터 변경이 자유롭고 가벼운 RAG 방식을 채택했습니다. 이번 3편에서는 JSON 형태의 사주 지식 데이터를 자바 객체로 가공하고, 임베딩 모델을 통해 벡터로 변환하여 Spring AI의 SimpleVectorStore에 주입(Ingestion)하는 전체 과정을 다룹니다.
2. 데이터 주입 파이프라인 (Data Ingestion Pipeline)
서버 부팅 시 외부 JSON 파일이 벡터화되어 RAM 상의 벡터 스토어에 색인되는 전체 흐름도입니다.
subgraph SpringApp ["Spring Boot Ingestion Runner"]
Loader[SajuDataLoader]
Parser[Jackson ObjectMapper]
DocMaker[Document Chunking]
end
subgraph AI_Engine ["Embedding Process"]
EmbedModel["OllamaEmbeddingModel (nomic-embed-text)"]
VectorMath["Float Array (1536-dim)"]
end
subgraph Vector_DB ["In-Memory Store"]
VStore["SimpleVectorStore (RAM)"]
end
JSON -->|1. 파일 읽기| Loader
Loader -->|2. DTO 역직렬화| Parser
Parser -->|3. 의미 단위 텍스트 조합| DocMaker
DocMaker -->|4. 원문 전송| EmbedModel
EmbedModel -->|5. 고차원 수치 벡터 생성| VectorMath
VectorMath -->|6. 색인 및 등록| VStore
3. 사주 도메인 JSON 데이터 스키마 설계
RAG 검색의 핵심은 **“검색 쿼리와 지식 문서 간의 의미적 밀접도”**입니다. 모델이 사용자의 고민(예: 야근, 마감, 레거시 코드)을 감지했을 때 적절한 안티패턴을 찾을 수 있도록 JSON 데이터를 설계했습니다.
[
{
"id": "saju-gap-ja",
"stem_branch": "갑자(甲子)",
"element": "수생목(水生木) / 레거시 부트스트래핑",
"it_interpretation": "기초 인프라가 탄탄하지 않은 상태에서 무리하게 비즈니스 로직을 확장하려는 경향이 강함.",
"anti_pattern": "빅뱅 방식의 전면 재구축 (Big Bang Rewrite)",
"bad_habit": "문서화 없이 혼자 모든 코드를 머릿속에 담아두고 개발함.",
"prescription": "도메인 주도 설계(DDD)를 도입하고, 점진적인 스트랭글러 피그(Strangler Fig) 패턴으로 리팩토링할 것."
},
{
"id": "saju-eul-chuk",
"stem_branch": "을축(乙丑)",
"element": "토극수(土克水) / 데이터베이스 병목",
"it_interpretation": "외부 API 지연과 DB 락 경합으로 인해 병목 구간이 심화되는 운세.",
"anti_pattern": "N+1 쿼리 방치 및 테이블 풀 스캔",
"bad_habit": "로컬에서는 빠른데 프로덕션에서 왜 느린지 프로파일링하지 않음.",
"prescription": "JPA Fetch Join을 적용하고 인덱스 실행 계획(EXPLAIN)을 철저히 분석할 것."
}
]
4. 데이터 로더 컴포넌트 구현 (SajuDataLoader)
Spring Boot의 ApplicationRunner를 구현하여 애플리케이션 시작 시점에 단 한 번 데이터 주입 작업이 실행되도록 구성합니다.
@Component
@RequiredArgsConstructor
@Slf4j
public class SajuDataLoader implements ApplicationRunner {
private final VectorStore vectorStore;
private final ObjectMapper objectMapper;
@Value("classpath:data/sajuAPI.json")
private Resource sajuJsonResource;
@Override
public void run(ApplicationArguments args) throws Exception {
log.info("==> [RAG] 사주 지식 데이터 벡터 주입(Ingestion) 시작");
// 1. JSON 파일 읽기 및 파싱
List<Map<String, Object>> sajuList = objectMapper.readValue(
sajuJsonResource.getInputStream(),
new TypeReference<List<Map<String, Object>>>() {}
);
// 2. Spring AI Document 객체로 변환
List<Document> documents = sajuList.stream()
.map(this::convertToDocument)
.toList();
// 3. VectorStore에 임베딩 및 저장 (일괄 배치)
vectorStore.add(documents);
log.info("==> [RAG] 총 {}건의 사주 벡터 데이터 주입 완료!", documents.size());
}
private Document convertToDocument(Map<String, Object> item) {
// 검색 대상이 될 본문 텍스트: LLM이 질의와 유사도를 계산할 핵심 문장들
String content = String.format(
"간지: %s | 오행 성향: %s\nIT 해석: %s\n발생 안티패턴: %s\n개발 나쁜 습관: %s\n처방전: %s",
item.get("stem_branch"),
item.get("element"),
item.get("it_interpretation"),
item.get("anti_pattern"),
item.get("bad_habit"),
item.get("prescription")
);
// 부가 메타데이터 (필터링 및 UI 표시용)
Map<String, Object> metadata = Map.of(
"id", item.get("id"),
"stem_branch", item.get("stem_branch"),
"anti_pattern", item.get("anti_pattern")
);
return new Document(content, metadata);
}
}
5. SimpleVectorStore 설정 및 영구 캐싱 팁
Spring AI의 SimpleVectorStore는 별도의 DB 설치 없이 EmbeddingModel만 주입받으면 즉시 인메모리에서 동작합니다.
@Configuration
public class VectorStoreConfig {
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
return new SimpleVectorStore(embeddingModel);
}
}
재부팅 시 재임베딩 방지 (Local File Save/Load)
매번 서버를 켤 때마다 임베딩 API를 호출하면 부팅이 수십 초 지연됩니다. SimpleVectorStore는 파일로 직렬화하여 저장하고 복원하는 기능을 기본 제공합니다.
File vectorFile = new File("src/main/resources/data/saju_vectors.json");
if (vectorFile.exists()) {
// 이미 계산된 벡터 파일이 있으면 즉시 역직렬화 (1초 미만)
simpleVectorStore.load(vectorFile);
} else {
// 최초 1회만 임베딩 계산 후 파일로 저장
simpleVectorStore.add(documents);
simpleVectorStore.save(vectorFile);
}
6. 유사도 검색(Similarity Search) 테스트 검증
실제 사용자의 고민 문장이 주어졌을 때 벡터 저장소가 얼마나 정확하게 문서를 찾아내는지 테스트 코드로 검증합니다.
@SpringBootTest
public class VectorStoreTest {
@Autowired
private VectorStore vectorStore;
@Test
void 유사도_검색_테스트() {
String userQuery = "요즘 DB 쿼리가 너무 느리고 서버가 뻗으려고 해요.";
SearchRequest request = SearchRequest.query(userQuery)
.withTopK(2)
.withSimilarityThreshold(0.6);
List<Document> results = vectorStore.similaritySearch(request);
for (Document doc : results) {
System.out.println("검색된 문서: " + doc.getContent());
System.out.println("메타데이터: " + doc.getMetadata());
}
assertThat(results).isNotEmpty();
assertThat(results.get(0).getContent()).contains("데이터베이스 병목");
}
}
7. 결론 및 다음 편 예고
이제 우리 시스템은 사용자의 자연어 질문을 의미 단위로 분석하여, 가장 적합한 사주 안티패턴 지식을 스스로 찾아낼 수 있는 ‘검색 눈(Eye)‘을 갖추었습니다.
하지만 검색된 지식을 그대로 출력하면 딱딱한 데이터 목록에 불과합니다. 다음 4편에서는 **검색된 원본 지식에 ‘실리콘밸리 15년 차 시니컬한 시니어 개발자’의 인격을 입히고 엄격한 답변 형식을 강제하는 ‘프롬프트 엔지니어링 실전’**을 다루겠습니다.