0. 들어가며 — LLM에 사내 데이터를 연결하는 세 가지 방법
사전 학습된 LLM은 우리 회사의 규정도, 어제 올라온 문서도 모른다. 기업 내부 정보를 LLM에 연결하는 방법은 크게 세 가지다.
- 파인 튜닝(fine-tuning) — 모델 자체를 재학습. 비용이 크고 데이터 갱신마다 반복해야 한다.
- 도구 호출 방식(MCP, Model Context Protocol) — LLM이 실시간으로 외부 시스템을 조회.
- 검색 증강 생성(RAG, Retrieval-Augmented Generation) — 질문과 관련된 문서를 검색해 프롬프트에 주입. 비용·신선도·근거 제시의 균형이 좋아 가장 널리 쓰인다.
이 글은 세 번째 방법인 RAG를 Spring AI로 구현하는 과정을 정리한 것이다. Spring AI는 RAG를 모듈형 아키텍처로 지원하며, 주요 벡터 데이터베이스를 거의 전부(PGVector, Chroma, Elasticsearch, Milvus, Pinecone, Qdrant, Redis 등) 추상화해 준다.
Retrieval Augmented Generation :: Spring AI Reference
Retrieval Augmented Generation (RAG) is a technique useful to overcome the limitations of large language models that struggle with long-form content, factual accuracy, and context-awareness. Spring AI supports RAG by providing a modular architecture that a
docs.spring.io
Introduction :: Spring AI Reference
Support for all major Vector Database providers such as Apache Cassandra, Azure Cosmos DB, Azure Vector Search, Chroma, Elasticsearch, GemFire, MariaDB, Milvus, MongoDB Atlas, Neo4j, OpenSearch, Oracle, PostgreSQL/PGVector, Pinecone, Qdrant, Redis, SAP Han
docs.spring.io
1. 왜 벡터인가 — 임베딩과 유사도
문서를 벡터로 만드는(임베딩하는) 이유는 단 하나, 유사도 검색을 하기 위해서다. 키워드가 달라도 의미가 가까우면 벡터 공간에서 가까이 놓이고, 그 "가까움"은 코사인 유사도 기반 거리로 잰다. 이때 두 가지 수치를 혼동하기 쉬우니 정리해 두자.
| 수치 | 범위 | 해석 |
| Spring AI의 score (유사도 점수) | 0 ~ 1 | 1에 가까울수록 유사, 0에 가까울수록 다름 |
| pgvector의 <=> (코사인 거리) | 0 ~ 2 | 0에 가까울수록 유사 — score와 방향이 반대 |
Vector Databases :: Spring AI Reference
Some vector stores require their backend schema to be initialized before usage. It will not be initialized for you by default. You must opt-in, by passing a boolean for the appropriate constructor argument or, if using Spring Boot, setting the appropriate
docs.spring.io
2. VectorStore 인터페이스 — 검색과 필터링
Spring AI의 VectorStore 인터페이스에서 저장 단위는 Document이고, 테이블 관점으로는 row 하나가 document 하나다. 검색은 similaritySearch()에 SearchRequest를 넘기는 방식이다 — topK(상위 몇 건), similarityThreshold(점수 하한), 그리고 filterExpression(메타데이터 필터)을 조합한다. 필터는 문자열 표현식으로 쓸 수도 있고, 타입 안전한 FilterExpressionBuilder로 조립할 수도 있다.
public List<Document> searchDocument2(String question) {
List<Document> documents = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(1)
.similarityThreshold(0.4)
.filterExpression("source == '헌법' && year >= 1987")
.build());
return documents;
}
public List<Document> searchDocument2(String question) {
FilterExpressionBuilder feb = new FilterExpressionBuilder();
List<Document> documents = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(1)
.similarityThreshold(0.4)
.filterExpression(feb
.and(
feb.eq("source", "헌법"),
feb.gte("year", 1987))
.build())
.build());
return documents;
}
3. 추상화 아래로 — pgvector 네이티브 SQL
추상화가 편하지만, 원리를 이해하려면 한 번은 SQL로 내려가 보는 것이 좋다. 얼굴 인식 데모(얼굴 이미지를 벡터화해 저장하고, 입력 얼굴과 가장 유사한 3건을 찾는 예제)에서는 pgvector의 <=> 연산자로 코사인 거리를 직접 계산했다. 위 표에서 봤듯 거리이므로 0에 가까울수록 유사하고, ORDER BY로 오름차순 정렬하면 가장 닮은 얼굴이 먼저 나온다.
// 유사한 얼굴 찾기: <=>는 pgvector 코사인 거리(0~2)를 구하는 연산자임(0에 가까울수록 유사) / socre 와 반대
String sql = """
SELECT content, (embedding <=> ?::vector) AS similarity
FROM face_vector_store
ORDER BY embedding <=> ?::vector
LIMIT 3
""";
4. 문서를 집어넣기 — ETL 파이프라인
RAG의 절반은 데이터 적재다. Spring AI는 이를 ETL(Extract·Transform·Load) 파이프라인으로 모델링한다 — 원천에서 추출(DocumentReader)하고, 임베딩·검색에 적합하게 변환(DocumentTransformer)하고, 벡터 저장소에 적재(vectorStore.add)한다.
- Extract — 파일 형식별 DocumentReader: TextReader(.txt), PagePdfDocumentReader(.pdf), TikaDocumentReader(.doc/.docx)
- Transform — TokenTextSplitter가 대표적. 문자 수가 아니라 LLM 토크나이저 기준 토큰 수로 분할하기 때문에, 청크가 모델 컨텍스트에 안전하게 들어간다.
- Load — 변환된 Document 리스트를 vectorStore.add()로 적재
업로드 파일의 Content-Type에 따라 Reader를 분기하는 전형적인 추출 코드다.
// ##### 업로드된 파일로부터 텍스트를 추출하는 메소드 #####
private List<Document> extractFromFile(MultipartFile attach) throws IOException {
// 바이트 배열을 Resource로 생성
Resource resource = new ByteArrayResource(attach.getBytes());
List<Document> documents = null;
if (attach.getContentType().equals("text/plain")) {
// Text(.txt) 파일일 경우
DocumentReader reader = new TextReader(resource);
documents = reader.read();
} else if (attach.getContentType().equals("application/pdf")) {
// PDF(.pdf) 파일일 경우
DocumentReader reader = new PagePdfDocumentReader(resource);
documents = reader.read();
} else if (attach.getContentType().contains("wordprocessingml")) {
// Word(.doc, .docx) 파일일 경우
DocumentReader reader = new TikaDocumentReader(resource);
documents = reader.read();
}
return documents;
}
JSON 원천이라면 JsonReader에 어떤 필드를 본문으로 읽고 어떤 값을 메타데이터로 남길지(JsonMetadataGenerator) 지정한다. 이 메타데이터가 2장의 filterExpression에서 필터 조건으로 쓰인다 — E·T·L이 한 메소드에 그대로 드러나는 예제다.
// ##### JSON의 ETL 과정을 처리하는 메소드 #####
public String etlFromJson(String url) throws Exception {
// URL로부터 Resource 얻기
Resource resource = new UrlResource(url);
// E: 추출하기
JsonReader reader = new JsonReader(
resource,
new JsonMetadataGenerator() {
@Override
public Map<String, Object> generate(Map<String, Object> jsonMap) {
return Map.of(
"title", jsonMap.get("title"),
"author", jsonMap.get("author"),
"url", "http://localhost:8080/document/constitution(19880225).json");
}
},
/*jsonMap -> Map.of(
"title", jsonMap.get("title"),
"author", jsonMap.get("author"),
"url", "http://localhost:8080/document/constitution(19880225).json"
),*/
"date", "content"
);
List<Document> documents = reader.read();
log.info("추출된 Document 수: {} 개", documents.size());
// T: 변환하기
DocumentTransformer transformer = new TokenTextSplitter();
List<Document> transformedDocuments = transformer.apply(documents);
log.info("변환된 Document 수: {} 개", transformedDocuments.size());
// L: 적재하기
vectorStore.add(transformedDocuments);
return "JSON에서 추출-변환-적재 완료 했습니다.";
}
5. QuestionAnswerAdvisor — RAG를 한 줄로
Spring 개발자에게 익숙한 개념이 여기서 재등장한다. Advisor는 Spring AOP의 실행 단위이고, Spring AI는 같은 패턴으로 LLM 호출 전후에 로직을 끼워 넣는다. QuestionAnswerAdvisor를 ChatClient에 걸면 — 사용자 질문으로 벡터 검색을 수행하고, 검색된 문서를 프롬프트에 자동 주입하며, "컨텍스트에 없으면 모른다고 답하라"는 근거 기반 답변 구조를 강제한다. RAG 오케스트레이션을 직접 짤 필요가 없어지는 것이다.
// 프롬프트를 LLM으로 전송하고 응답을 받는 코드
String answer = this.chatClient.prompt()
.system("프롬프트 컨텍스트에 없는 내용은 모른다고 답변을 하세요.")
.user(question)
.advisors(questionAnswerAdvisor)
.call()
.content();
return answer;
실행 로그를 보면 동작이 명확해진다. qa_retrieved_documents에 헌법 조문 청크들이 score와 함께 담겨 프롬프트로 들어가고, 답변은 그 근거 안에서만 생성된다.
// 프롬프트를 LLM으로 전송하고 응답을 받는 코드
String answer = this.chatClient.prompt()
.user(question)
.advisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(), // 프롬프트에 이전 대화 기록 추가, 이전 대화 이력(Context)을 프롬프트에 주입
retrievalAugmentationAdvisor // 이전 대화기록을 완전한 대화 완성, VectorStore 검색 결과(Context)를 프롬프트에 주입
)
.advisors(advisorSpec -> advisorSpec.param(
ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
return answer;
}
6. ChatModel vs ChatClient — 무엇을 쓸 것인가
마지막으로 API 선택 기준. 결론부터 말하면 실무에서는 ChatClient다. ChatModel은 저수준 API로 Advisor·RAG·Memory 지원이 없고, ChatClient가 이 모든 것을 얹은 고수준 인터페이스다.
| 추상화 레벨 | Low-level | High-level |
| 실제 LLM 호출 | ✅ | 내부적으로 사용 |
| Advisor 지원 | ❌ | ✅ |
| RAG 지원 | ❌ | ✅ |
| Prompt Template | 제한적 | 풍부 |
| Memory | ❌ | ✅ |
| 실무 권장 | ❌ (직접 사용 X) | ✅ |
7. 마치며
정리하면 — 문서를 ETL로 벡터 저장소에 적재하고, VectorStore의 유사도 검색(+메타데이터 필터)으로 근거를 찾고, QuestionAnswerAdvisor로 검색-주입-답변을 자동화한다. score와 거리의 방향이 반대라는 것, 분할은 토큰 기준(TokenTextSplitter)이라는 것만 기억해도 흔한 함정은 피할 수 있다. 전자정부 표준프레임워크(eGovFrame) 기반 공공 프로젝트에도 같은 구조로 적용할 수 있다.
표준프레임워크 포털 eGovFrame
본문 내용 바로가기 대메뉴 바로가기 소개 구성 구성상세 버전별 구성 오픈소스 SW 현황 아키텍쳐 실행환경 개발환경 운영환경 관리환경 공통컴포넌트 모바일 라이선스 적용사례 추진성과 기술
www.egovframe.go.kr
'푸닥거리' 카테고리의 다른 글
| Google ADK 기반 AI Agent 개발 (0) | 2026.03.29 |
|---|---|
| 바이브 코딩을 넘어 Github Spec Kit으로 구현하는 SDD(Spec Driven Development) and hermes, harness (0) | 2026.01.25 |
| Label Shift vs Covariate Shift — 실무 개발자를 위한 10분 가이드 (0) | 2025.08.23 |
| Sequence-to-Sequence (Seq2Seq) 모델 완벽 정리 — RNN vs Transformer 비교 (0) | 2025.08.16 |
| Inductive vs Transductive Learning – 무엇이 다를까? (0) | 2025.07.26 |
댓글