Spring

Advisor로 LLM에 추가 정보 제공 및 응답 가공하기 (feat. RAG)

수수한 인간 2025. 8. 3. 21:59

들어가면서

Spring AI는 최근 1.0.0 정식 버전으로 출시되며, 내부 구조와 주요 기능이 대폭 정리되었습니다.
그중에서도 LLM을 실무에서 안전하고 유연하게 다루기 위해 반드시 이해해야 할 핵심 기능이 바로 Advisor입니다.

Advisor는 LLM 호출 전/후의 데이터를 가공하거나 보강할 수 있는 인터셉터(Interceptor) 역할을 수행합니다.
이를 통해 외부 정보를 주입하거나, 민감한 응답을 걸러내는 등의 고급 흐름 제어가 가능합니다.

Spring AI의 Advisor는 다음과 같은 작업에 활용됩니다:

  • 프롬프트 전처리: 고정 지시문 추가, 외부 문서 삽입(RAG), 로깅 등
  • 응답 후처리: 민감한 내용 필터링, 마스킹, 응답 포맷 보정, 로깅 등

이번 글에서는 Advisor의 구조와 동작 방식을 예제와 함께 정리해보겠습니다:

  1. Advisor의 실제 동작 순서
  2. 전처리 (지시문 삽입, RAG, 대화흐름 기억, 로깅)
  3. 후처리 (응답 필터링, 마스킹, 로깅)

사용방법

대신 아래처럼 단일 CallAdvisor 인터페이스를 사용하여 프롬프트 전처리와 응답 후처리를 함께 구현할 수 있습니다.

인터페이스 설명
CallAdvisor call() 방식에서 프롬프트/응답을 가공
StreamAdvisor stream() 방식에서 Flux 응답을 제어
동작 순서

1단계: 사용자 입력 생성
  ↓  
2단계: CallAdvisor(StreamAdvisor) 전처리
  ↓  
3단계: LLM 호출 
  ↓  
4단계: CallAdvisor(StreamAdvisor) 후처리  
  ↓  
5단계: 최종 응답 반환  

여러 Advisor가 존재할 경우 getOrder()를 통해 실행 순서를 제어할 수 있습니다.

전처리(LLM 호출 전)에서는 order 값이 낮을수록 먼저,
후처리(LLM 호출 후)에서는 order 값이 높을수록 먼저 실행됩니다.

즉, 전처리 → LLM 호출 → 후처리 순서를 기준으로
낮은 order부터 동작하고 LLM 호출 후 다시 높은 order부터 역순으로 동작하는 방식이라고 이해하면 좋습니다.


예제코드

  1. 기본적으로 chatClient에 연결하는 방법
  2. 전처리 - ReReadingAdvisor 예시
  3. 후처리 - BlockedKeywordAdvisor 예시
  4. RAG

기본적으로 chatClient에 연결하는 방법

advisor는 ChatClient에 설정하는 2가지 방식이 있습니다.

  1. default로 설정하는 방법
  2. 실행시 advisor를 추가하는 방법
@AllArgsConstructor
@RestController
public class Advisor {
    private final ChatModel chatModel;

    @GetMapping("/v1/advisor")
    public ChatResponse advisor(@RequestParam("text") String text){

        Prompt prompt = new Prompt(
                List.of(
                        new SystemMessage("너는 친절한 AI 비서야. 절대 추론과정을 보여주지 말고 요약만 응답해."),
                        new UserMessage( text )
                ));

        ChatClient chatClient = ChatClient.builder(chatModel)
                .defaultAdvisors(new SimpleLoggerAdvisor(1)) //1. default로 advisor 연결
                .build();

        ChatResponse response = chatClient
                .prompt(prompt)
                .advisors(new ReReadingAdvisor(0)) // 2. 실행시 advisor 연결
                .call()
                .chatResponse();
        return response;
    }
}

전처리 - ReReadingAdvisor 예시

전처리는 LLM이 더 정확한 응답을 할 수 있도록 도와주는 작업입니다.
다음과 같은 상황에서 전처리를 활용할 수 있습니다:

  • 질문에 시스템 프롬프트를 추가하고 일관된 응답 유도
  • 외부 정보를 프롬프트에 삽입 (예: RDB, Vector DB, 웹 크롤러 등)
  • 금칙어 입력 시 고정 응답 제공
  • LLM의 추론력을 강화하는 프롬프트 설계

Spring AI 공식 문서에서도 소개된 예제인 ReReadingAdvisor를 직접 구현해보겠습니다.
이 방식은 "질문을 두 번 읽게 하자"는 단순한 입력 전략(Re-Reading, Re2)을 통해 LLM의 추론 정확도를 향상시킵니다.

public class ReReadingAdvisor implements CallAdvisor, StreamAdvisor {

    private int order = 1;
    public ReReadingAdvisor(){
    }
    public ReReadingAdvisor(int order){
        this.order = order;
    }

    // 추론 능력을 향상시키는 다시 읽기(Re-Reading, Re2)라는 기법을 적용(Spring ai에서 예시로 소개함)
    private ChatClientRequest before(ChatClientRequest advisedRequest) {
        // 입력한 사용자 프롬프트를 가져온다.
        String userText = advisedRequest.prompt().getUserMessage().getText();
        // 프롬프트에 추가적인 내용을 적어서 전달
        String augmented = userText + "\nRead the question again: " + userText;

        return advisedRequest.builder()
                .prompt(advisedRequest.prompt().augmentUserMessage(augmented))
                .context(advisedRequest.context())
                .build();
    }
    // 한번에 응답해주는 함수
    @Override
    public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
        // llm에 요청전 Re-Reading, Re2라는 기법 적용
        ChatClientRequest newChatClientRequest = this.before(chatClientRequest);
        // llm 호출
        ChatClientResponse chatClientResponse =callAdvisorChain.nextCall(newChatClientRequest);
        return chatClientResponse;
    }

    // 스트리밍식으로 사용되는 함수
    @Override
    public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain) {

        return streamAdvisorChain.nextStream(this.before(chatClientRequest));
    }

    @Override
    public String getName() {
        // advisor 이름
        return this.getClass().getSimpleName();
    }

    @Override
    public int getOrder() {
        // 실행되는 우선 순위
        return this.order;
    }
}

후처리 - BlockedKeywordAdvisor 예시

후처리는 LLM의 응답을 최종적으로 점검하거나 수정하는 데 사용됩니다.
예를 들어 다음과 같은 작업을 처리할 수 있습니다:

  • 특정 금칙어 마스킹
  • 불필요한 태그 제거 (예: <think> 등)
  • 너무 긴 응답 차단
  • 응답을 구조화(JSON, Markdown, DTO)

아래는 금칙어를 ***로 마스킹 처리하는 BlockedKeywordAdvisor의 예제입니다.

public class BlockedKeywordAdvisor implements CallAdvisor {

    private List<String> blockedWords = List.of("죽음", "자살");
    private int order = 0;
    public BlockedKeywordAdvisor(){
    }
    public BlockedKeywordAdvisor(List<String> keywords){
        this.blockedWords = keywords;
    }
    public BlockedKeywordAdvisor(List<String> keywords, int order){
        this.blockedWords = keywords;
        this.order = order;
    }


    @Override
    public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
        ChatClientResponse chatClientResponse =callAdvisorChain.nextCall(chatClientRequest);

        //Generation는 llm의 응답을 담은 객체
        List<Generation> sanitizedGenerations = new ArrayList<>();
        for(Generation generation : chatClientResponse.chatResponse().getResults()){
            String outputText= generation.getOutput().getText();
            for(String blockedWord : blockedWords){
                // 응답에서 금칙어 ***로 변경하는 코드
                outputText = outputText.replaceAll(blockedWord,"***");
            }
            sanitizedGenerations.add(new Generation(new AssistantMessage(outputText),generation.getMetadata()));
        }
        // 변경한 답변으로 새로운 ChatResponse를 만듬
        ChatResponse chatResponse = ChatResponse.builder()
                                                .from(chatClientResponse.chatResponse())
                                                .generations(sanitizedGenerations)
                                                .build();
        return ChatClientResponse.builder()
                                .chatResponse(chatResponse)
                                .context(chatClientResponse.context())
                                .build();
    }

    @Override
    public String getName() {
        // advisor 이름
        return this.getClass().getSimpleName();
    }

    @Override
    public int getOrder() {
        // 실행되는 우선 순위
        return this.order;
    }
}

RAG

RAG는 전처리 방식 중 하나로, Vector DB에서 관련 정보를 검색한 뒤 LLM에게 함께 제공하여 더 정답에 가까운 응답을 유도하는 방식입니다.

  • 문서 → 임베딩 → Vector DB 저장
  • 질문 → 임베딩 → 유사도 기반 검색
  • 검색된 문서 + 질문 → 프롬프트 구성 → LLM 호출

이 방식을 실무에서 잘 적용하려면 다음 전략이 중요합니다:

  • 문서를 어떻게 Chunk로 나누고 저장할 것인가?
  • 어느 임계 유사도(similarity threshold) 이상만 검색할 것인가?
  • 검색 결과 몇 개(top-k)를 프롬프트에 포함할 것인가?

이번 예제는 Spring AI에서 제공하는 QuestionAnswerAdvisor를 사용하여 구현했습니다.

@RestController
@AllArgsConstructor
public class Rag {
    private final ChatModel chatModel;
    private final VectorStore vectorStore;

    // 입력 텍스트를 Vector DB에 저장
    @PostMapping("/v1/rag")
    public String addRag(@RequestBody String text){
        // 따로 vectorStore를 만들지 않으면 기본 설정된 embedding 모델이 들어간다.
        List<Document> documents = List.of(new Document(text));
        vectorStore.add(documents);
        return "성공";
    }

    // 저장된 벡터 기반으로 검색 + RAG 수행
    @GetMapping("/v1/rag")
    public String findRag(@RequestParam String text){
        // <query> 는 유저의 질문으로 변경됨
        // <question_answer_context>는 Vector DB에 조회한 데이터로 변경됨
        // 프롬프트 템플릿 정의
        PromptTemplate customPromptTemplate = PromptTemplate.builder()
                .renderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
                .template("""
            <query>

            맥락 정보는 아래와 같습니다.

            ---------------------
            <question_answer_context>
            ---------------------

            맥락 정보에 주어진 내용으로만 대답해

            다음 규칙을 따르십시오:

            1. 만약 문맥상 답이 명확하지 않다면, 그냥 모른다고 말하세요.
            2. "맥락에 따라..." 또는 "제공된 정보에 따르면..."과 같은 표현은 피하세요.
            3. 제공된 맥락 정보도 같이 알려줘
            """)
                .build();
        // 검색 정책 설정
        SearchRequest searchRequest =SearchRequest.builder()
                                                    // 유사도 20% 이상만 포함
                                                    .similarityThreshold(0.2)
                                                    // 상위 5개 검색 결과 사용
                                                    .topK(5)
                                                    .build();
        // RAG Advisor 생성
        QuestionAnswerAdvisor qnaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
                                                    .searchRequest(searchRequest)
                                                    .build();
        // ChatClient 구성 후 요청 실행
        ChatClient chatClient = ChatClient.builder(chatModel).build();
        return chatClient
                .prompt(text) // 유저의 질문
                .advisors(qnaAdvisor)// RAG 기반 전처리 수행
                .call()
                .content();
    }
}

Spring Ai에서 제공하는 Advisor 종류

Chat Memory Advisors(채팅 메모리 저장소에서 대화 기록을 관리)

  • MessageChatMemoryAdvisor: 메모리를 검색하여 메시지 모음으로 프롬프트에 추가
  • PromptChatMemoryAdvisor: 메모리를 검색하여 프롬프트의 시스템 텍스트에 통합
  • VectorStoreChatMemoryAdvisor: VectorStore에서 메모리를 검색하여 프롬프트의 시스템 텍스트에 추가Question Answering Advisor
  • QuestionAnswerAdvisor: 벡터 저장소를 사용하여 질의 응답 기능을 제공하고 Naive RAG(검색 증강 생성) 패턴을 구현
  • RetrievalAugmentationAdvisor: 패키지에 정의된 빌딩 블록을 사용하고 모듈식 RAG 아키텍처를 따르는 일반적인 검색 증강 생성(RAG) 흐름을 구현Content Safety Advisor
  • SafeGuardAdvisor: 금칙어 감지하는 간단한 advisor

마치며

Spring AI를 공부하면서 인상 깊었던 기능 중 하나가 바로 Advisor였습니다.
이 기능을 통해 LLM에게 외부 데이터를 무궁무진하게 전달할 수 있었고,
응답을 필터링하거나 민감한 정보 노출을 막는 등 실무적인 보안 이슈도 효과적으로 다룰 수 있었습니다.

특히 Advisor는 다음과 같은 측면에서 강력한 도구입니다:

  • 전처리를 통해 프롬프트를 강화하고 (예: 시스템 지시문, RAG, 프롬프트 보정)
  • 후처리를 통해 불필요하거나 유해한 응답을 차단하거나 마스킹할 수 있으며
  • 외부 시스템과의 연동까지 가능하게 해 줍니다.

이번 글에서는 LLM에게 외부 정보를 주는 방식을 알아봤다면,
다음 포스팅에서는 LLM이 외부 기능을 직접 호출하는 방식,
Function Calling (Tool Calling) 을 다룰 예정입니다.

이 기능은 단순한 Q&A를 넘어,
LLM이 메서드를 호출하고, API에 연결하며, 데이터를 읽고 쓰는
진짜 에이전트(AI Agent)의 세계로 나아가는 첫걸음이라고 생각합니다.

해당 예시는 github에 있습니다.
url: https://github.com/Zero-Human/springAi

참고자료