spring airag 3일차 / Rag, springairag2

2026. 5. 26. 18:45대우개발원 수업 내용/spring기반 ai

반응형

KnowledgeService

코드 수정

더보기
package com.example.springairag2.service;

import com.example.springairag2.dto.AskResponse;
import com.example.springairag2.dto.SaveRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * 사내 지식베이스 RAG 서비스
 *
 * [저장 흐름]
 *   텍스트 + 카테고리/출처
 *     → OpenAI Embedding API → 1536차원 벡터
 *     → pgvector 저장 (content, metadata, embedding)
 *
 * [RAG 질의 흐름]
 *   질문
 *     → 벡터 변환 → pgvector 코사인 유사도 검색
 *     → 관련 문서 Top-4 추출
 *     → [문서 컨텍스트 + 질문] → GPT → 답변 + 출처 반환
 */
@Slf4j
@Service
public class KnowledgeService {

    private final VectorStore vectorStore;
    private final ChatClient  chatClient;

    public KnowledgeService(VectorStore vectorStore, ChatModel chatModel) {
        this.vectorStore = vectorStore;
        this.chatClient  = ChatClient.builder(chatModel)
                .defaultSystem("""
                        당신은 회사 내부 지식베이스를 기반으로 답변하는 AI 어시스턴트입니다.
                        
                        규칙:
                        1. 반드시 제공된 [참고 문서]의 내용을 근거로 답변하세요.
                        2. 참고 문서에 없는 내용은 "해당 내용을 사내 문서에서 찾을 수 없습니다."라고 답하세요.
                        3. 답변은 명확하고 구체적으로 작성하세요.
                        4. 가능하면 요점을 번호 목록으로 정리하세요.
                        """)
                .build();
    }

    // ── 1. 문서 저장 ─────────────────────────────────────────────────
    public String save(SaveRequest req) {
        Map<String, Object> metadata = Map.of(
                "category", req.getCategory() != null ? req.getCategory() : "일반",
                "source",   req.getSource()   != null ? req.getSource()   : "미분류"
        );
        Document doc = new Document(req.getContent(), metadata);
        vectorStore.add(List.of(doc));
        log.debug("[저장] id={} category={} source={}", doc.getId(),
                req.getCategory(), req.getSource());
        return doc.getId();
    }

    // ── 2. 유사도 검색 ────────────────────────────────────────────────
    public List<Document> search(String query, String category, int topK) {

        // 카테고리 필터 (null이면 전체 검색)
        String filter = (category != null && !category.isBlank())
                ? "category == '" + category + "'"
                : null;

        SearchRequest.Builder builder = SearchRequest.builder()
                .query(query)
                .topK(topK)
                .similarityThreshold(0.3);

        if (filter != null) {
            builder.filterExpression(filter);
        }

        List<Document> results = vectorStore.similaritySearch(builder.build());
        log.debug("[검색] query='{}' category='{}' → {}건", query, category, results.size());
        return results;
    }

    // ── 3. RAG 질의응답 ───────────────────────────────────────────────
    public AskResponse ask(String question, String category) {

        // Step 1. 관련 문서 검색
        List<Document> docs = search(question, category, 4);

        // Step 2. 검색 결과 없을 때 처리
        if (docs.isEmpty()) {
            return AskResponse.builder()
                    .question(question)
                    .answer("해당 내용을 사내 문서에서 찾을 수 없습니다. 문서를 먼저 등록해 주세요.")
                    .sources(List.of())
                    .build();
        }

        // Step 3. 컨텍스트 조합
        String context = docs.stream()
                .map(d -> "[출처: %s / %s]\n%s".formatted(
                        d.getMetadata().getOrDefault("category", ""),
                        d.getMetadata().getOrDefault("source", ""),
                        d.getText()))
                .collect(Collectors.joining("\n\n"));

        // Step 4. GPT 호출
        String answer = chatClient.prompt()
                .user("""
                        [참고 문서]
                        %s
                        
                        [질문]
                        %s
                        """.formatted(context, question))
                .call()
                .content();

        // Step 5. 출처 문서 목록 구성
        List<AskResponse.SourceDoc> sources = docs.stream()
                .map(d -> AskResponse.SourceDoc.builder()
                        .content(truncate(d.getText(), 120))
                        .category(String.valueOf(d.getMetadata().getOrDefault("category", "")))
                        .source(String.valueOf(d.getMetadata().getOrDefault("source", "")))
                        .score(0.0)   // pgvector는 score를 별도로 제공하지 않음
                        .build())
                .toList();
        log.debug("[RAG] question='{}' → answer length={}", question, answer.length());
        return AskResponse.builder()
                .question(question)
                .answer(answer)
                .sources(sources)
                .build();
    }

    // ── 4. 전체 문서 수 조회 ─────────────────────────────────────────
    // (간단히 검색으로 근사치 파악)
    public int countDocs() {
        try {
            return vectorStore.similaritySearch(
                    SearchRequest.builder()
                            .query("회사 규정 정책 기술 온보딩")
                            .topK(1000)
                            .similarityThreshold(0.0)
                            .build()
            ).size();
        } catch (Exception e) {
            return 0;
        }
    }

    // ── 샘플 데이터 일괄 적재 ─────────────────────────────────────────
    public void loadSampleData() {
        List<SaveRequest> samples = buildSampleData();
        for (SaveRequest req : samples) {
            save(req);
        }
        log.info("[샘플 데이터] {}건 적재 완료", samples.size());
    }

    private String truncate(String s, int max) {
        return s.length() > max ? s.substring(0, max) + "…" : s;
    }

    private List<SaveRequest> buildSampleData() {
        List<Object[]> data = List.of(
                // [content, category, source]
                new Object[]{"연차 유급휴가는 1년 이상 근속한 직원에게 15일이 부여됩니다. 3년 이상 근속 시 1일씩 추가되며 최대 25일까지 부여됩니다. 연차는 전년도 출근율 80% 이상인 경우에만 전일 부여됩니다.", "HR", "취업규칙 제24조"},
                new Object[]{"육아휴직은 만 8세 이하 또는 초등학교 2학년 이하 자녀를 가진 직원이 신청 가능합니다. 최대 1년까지 사용할 수 있으며, 배우자와 분할 사용도 가능합니다. 신청은 휴직 개시 30일 전까지 인사팀에 제출해야 합니다.", "HR", "육아휴직 규정"},
                new Object[]{"재택근무 신청은 매주 수요일 오전까지 그룹웨어에서 신청합니다. 주 2회까지 허용되며, 부서장 사전 승인이 필요합니다. 재택 중에도 코어타임(10시~16시)에는 화상 연결 가능 상태를 유지해야 합니다.", "HR", "재택근무 운영지침"},
                new Object[]{"경조사 지원 기준: 본인 결혼 5일, 배우자 출산 10일, 부모/배우자 사망 5일, 조부모/자녀 사망 3일의 특별 휴가가 부여됩니다. 경조금은 결혼 20만원, 출산 10만원, 사망 10만원이 지급됩니다.", "HR", "경조사 지원 규정"},
                new Object[]{"모든 직원은 업무용 PC에 백신 소프트웨어를 반드시 설치해야 하며, 자동 업데이트를 활성화해야 합니다. 외부 저장장치(USB 등) 사용 시 보안팀 사전 승인이 필요합니다. 미승인 외부 저장장치 사용은 보안 위반으로 처리됩니다.", "정책", "정보보안 정책 v3.0"},
                new Object[]{"회사 기밀 정보는 외부 클라우드 서비스(개인 Google Drive, Dropbox 등)에 저장하거나 공유할 수 없습니다. 업무 자료 공유는 반드시 사내 협업 시스템(사내 드라이브)을 이용해야 합니다. 위반 시 징계 처분을 받을 수 있습니다.", "정책", "정보보안 정책 v3.0"},
                new Object[]{"비밀번호는 최소 8자 이상, 영문 대/소문자, 숫자, 특수문자를 조합해야 합니다. 90일마다 변경이 권장되며, 이전 5개의 비밀번호는 재사용할 수 없습니다. 타인과 비밀번호를 공유하는 행위는 엄격히 금지됩니다.", "정책", "비밀번호 관리 지침"},
                new Object[]{"출장 신청은 출장 3영업일 전까지 결재를 완료해야 합니다. 국내 출장 일비는 1일 3만원, 국외 출장은 지역별 차등 지급됩니다. 항공권은 이코노미석 원칙이며, 6시간 이상 장거리 노선은 비즈니스석이 허용됩니다.", "정책", "출장 관리 규정"},
                new Object[]{"Spring AI 2.0은 OpenAI, Anthropic, Google Gemini 등 다양한 AI 모델을 통합 지원합니다. VectorStore 인터페이스를 통해 pgvector, Elasticsearch, Pinecone 등 주요 벡터 DB와 연동할 수 있습니다. 임베딩 모델은 text-embedding-3-small(1536차원)을 기본으로 사용합니다.", "기술", "Spring AI 2.0 공식 문서"},
                new Object[]{"pgvector는 PostgreSQL 확장으로 벡터 연산과 유사도 검색을 지원합니다. HNSW 인덱스를 사용하면 수백만 건의 벡터도 밀리초 내 검색이 가능합니다. 코사인 유사도, L2 거리, 내적 등 다양한 거리 함수를 지원합니다.", "기술", "pgvector 기술 가이드"},
                new Object[]{"RAG(Retrieval-Augmented Generation)는 LLM이 답변 생성 시 외부 지식베이스를 검색하여 참고하는 기술입니다. 환각(Hallucination)을 줄이고 최신 정보를 반영할 수 있어 기업 AI 어시스턴트 구축에 널리 사용됩니다.", "기술", "RAG 기술 가이드"},
                new Object[]{"Docker Compose를 이용한 개발 환경 구성: services 하위에 postgres(pgvector 이미지), app(Spring Boot) 컨테이너를 정의합니다. 환경변수는 .env 파일로 관리하며 docker-compose up -d 명령으로 전체 환경을 실행합니다.", "기술", "개발환경 설정 가이드"},
                new Object[]{"신입사원 입사 첫날: 오전 9시 인사팀 방문 → 사원증/장비 수령 → 보안 서약서 서명 → 오후 OT 교육(회사 소개, 복리후생, 보안 정책) 순서로 진행됩니다. 교육 후 소속 팀 배치 및 멘토 연결이 이루어집니다.", "온보딩", "신입사원 온보딩 가이드"},
                new Object[]{"복리후생 항목: 중식비 월 15만원 지원, 건강검진 연 1회(배우자 격년), 자녀 학자금 지원(고등학교~대학교), 사내 어린이집 운영, 헬스장 이용권 50% 지원, 도서구입비 분기 20만원 지원.", "온보딩", "복리후생 안내서"},
                new Object[]{"그룹웨어 초기 설정: 사내 포털(portal.company.com) 접속 → 초기 비밀번호 변경 → 이메일/메신저 계정 연동 → 전자결재 위임 설정 → 부서 공유 드라이브 접근 권한 신청 순서로 진행하세요.", "온보딩", "그룹웨어 사용 가이드"}
        );

        return data.stream().map(d -> {
            SaveRequest req = new SaveRequest();
            req.setContent((String) d[0]);
            req.setCategory((String) d[1]);
            req.setSource((String) d[2]);
            return req;
        }).toList();
    }
}

 


controller 패키지 추가

KnowledgeApiController 생성

더보기
package com.example.springairag2.controller;

import com.example.springairag2.dto.AskRequest;
import com.example.springairag2.dto.AskResponse;
import com.example.springairag2.dto.SaveRequest;
import com.example.springairag2.service.KnowledgeService;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@Slf4j
@RestController("/api")
@RequestMapping
@RequiredArgsConstructor
public class KnowledgeApiController {
    private final KnowledgeService knowledgeService;

    // -- POST /api/ask -> RAG 질의 응답----------
    @PostMapping("/ask")
    public ResponseEntity<AskResponse> ask(@RequestBody AskRequest request) {
        AskResponse response = knowledgeService.ask(
                request.getQuestion(),
                request.getCategory()
        );
        return ResponseEntity.ok(response);
    }
    // --- POST /api/docs -> 문서 저장 ------------
    @PostMapping("/docs")
    public ResponseEntity<Map<String, String>> save(@RequestBody SaveRequest request) {
        String id = knowledgeService.save(request);
        return ResponseEntity.ok(Map.of("id", id, "message", "문서가 저장되었습니다."));
    }
    // --- POST /api/docs/sample -> 샘플 데이터 적재 ------------
    @PostMapping("/docs/sample")
    public ResponseEntity<Map<String, String>> loadSample() {
        knowledgeService.loadSampleData();
        return ResponseEntity.ok(Map.of("message", "샘플 데이터 15건이 적재되었습니다."));
    }
    // --- GET /api/docs/count -> 문서 수 조회 ------------
    @GetMapping("docs/count")
    public ResponseEntity<Map<String, Integer>> count() {
        return ResponseEntity.ok(Map.of("count", knowledgeService.countDocs()));
    }
}

접근을 위한 controller 따로 구성

PageController 생성

더보기
package com.example.springairag2.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class PageController {
    // 메인 페이지 (채팅 UI)
    @GetMapping("/")
    public String index() { return "index"; }

    // 문서 관리 페이지
    @GetMapping("/docs")
    public String docs() { return "docs"; }
}

docs.html

index.html 코드 templates 폴더에 추가


 

실행하면

 

샘플 데이터를 적재 후 질문 하면 ai가 답변을 해줌