BackEnd/쿠링

[쿠링] Spring AI를 사용한 RAG 서비스 구축과 고민

샤아이인 2024. 7. 28.

 

평소 LLM 기반의 서비스에 관심이 어느 정도 있어 따로 Python 진영의 LangChain으로 작은 프로젝트를 만들어봤지만, 그 Spring의 그 맛(?)이 느껴지지 않아 다소 아쉽다고 생각하고 있었다.

 

하지만, 이번에 Spring AI의 1.0.0-snapshot 이 릴리즈 되었다!!

아직 정식 릴리즈된 버전은 아니지만, 우리 정도의 프로젝트에는 적용할 수 있을 정도의 버전이라 생각되어 적용하게 되었다.

 

이번 RAG(Retrieval-Augmented Generation) 시스템을 구축하면서 한 고민고 과정을 글로 남겨본다.

(ps, 아직 prompt 엔지니어링 과정에 대한 고민은 작성하지 않았다. 해당 내용은 별도의 글로 만들어 추후 업로드 할 예정이다!)

1. 고민거리

고민 1-1) 사용자에게 얼마나 제한을 둘 것 인가?

구현에 앞서 가장 큰 고민은, 바로 "돈" 이였다.

쿠링은 구성원들이 매달 내는 회비를 기반으로 서버가 운영되고 있다.

따라서 기존의 회비는 유지하면서, 서비스를 제공하려면 사용자에게 어느 정도의 제약이 필요하다 생각하였다.

 

1. 한달동안 몇 번의 질문이 가능하도록 해야 할까?

2. 한번의 질문의 max 길이는 얼마로 해야 할까?

3. 서버측에서 사용자의 질문을 기반으로 유사도 검색한 vector를 몇 개를 함께 전달해야 할까? (top k에 대한 고민)

 

사용자의 질문이 다음과 같다 가정하자.

5·18희망장학생 접수는 언제 시작하고? 언제 끝나지? 추가로 접수일을 까먹지 않으려면 어떻게 해야 할까?

 

이를 AI model에게 전달할 때 서버에서 가공하면 다음과 같이 변환된다.

You are a helpful assistant, conversing with a user about the subjects contained in a set of documents. Use the information from the DOCUMENTS section to provide accurate answers. If unsure or if the answer isn't found in the DOCUMENTS section, simply state that you don't know the answer.

Please answer in Korean.

QUESTION: {5·18희망장학생 접수는 언제 시작하고? 언제 끝나지? 추가로 접수일을 까먹지 않으려면 어떻게 해야 할까?}

DOCUMENTS: {2024년 (재)5·18기념재단 5·18희망장학생 모집 안내 1. 장학안내 가. 지급기관: (재)5·18기념재단 나. 선발분야: 5·18희망장학생 다. 장학금액: 100만원(생활비성) 라. 지급기간: 1학기 마. 선발인원: 26명 2. 신청자격 가. 국내 대학 재학생(4년제ㆍ전문대 무관, 전공제한 없음)중 아래 조건을 1개 이상 충족하는 자 1) 민주화운동 및 국가폭력피해자 (유)자녀 2) 5·18정신(민주·인권·평화)등 계승 공동체 기여 활동자 ※ 5·18민주화운동 유공자(본인)및 희생(유)자녀, 대학원생, 휴학생 해당 없음. 3. 신청 안내 가. 접수기간: 2024. 7. 8.(월) ~ 7. 11.(목) 나. 접수방법: 학생본인 신청서 및 제출 서류를 기한에 맞춰 이메일 제출 - 이메일: som@518.org 다. 제출 서류: [붙임1] 참조 4. 문의: (재)5·18기념재단 장학사업 담당자 - 주 소 : (61965) 광주광역시 서구 내방로 152, 1층 5·18기념재단 - 전 화 : 062-360-0516 - 이메일 : som@518.org ※ 선발분야별 신청자격, 구비서류 등 기타 자세한 사항은 붙임문서를 참고하시기 바랍니다.}

 

다음 token 수를 확인해 볼 수 있는 사이트에서 확인한 결과 700 토큰 정도가 나왔다.

https://platform.openai.com/tokenizer

계산의 편의성을 위해 사용자가 3줄 정도의 질문을 추가로 했다 가정하고, 질문 1번당 1000 token으로 가정하자.

 

쿠링이 사용하는 3.5 turbo model은 1,000,000 token당 $1.5 니까 -> 1000 token당 $0.0015에 해당된다.

1달에 사용자가 2번씩 사용하면 1인당 $0.0030 이 필요하고,

총 사용자 중 1/4 정도인 2000명이 사용한다 가정하면 달마다 $6 정도가 필요하다.

또한 우리의 input을 읽고, output을 생성할 때도 돈이 드는데, 응답 한 번당 500 토큰 이하로 가정하면 달마다 $3 이 필요하다

 

월 $9가 필요한 상황이다.

 

따라서 월 10$의 제한을 두고, 나머지 1$는 공지 자체를 vector화 하는 비용으로 사용해야겠다고 판단하였다.


이 글을 작성하고 있던 도중에 2024/07/18 일에 GPT-4o-mini가 출시하였다

https://openai.com/index/gpt-4o-mini-advancing-cost-efficient-intelligence/

1M 기준, 3.5-turbo 1M token당 $1.5에 비하여 4o-mini는 1M token당 $0.15로 무려 10배 저렴해졌다!

심지어 가격은 줄어들고, 성능은 더 개선되었다고 한다!!

 

2000명 기준 월 2회 질문 시 월 $0.9가 필요하게 되었다! 아주 합리적인 금액이다!

질문 횟수를 증가시켜도 될 거 같은데, 일단 첫 달은 베타 기간으로 변경 없이 2번만 질문 가능하도록 하고, 향후 질문 횟수를 변경할 예정이다.

 

고민 1-2) 어떤 방식으로 클라이언트와 소통할 것 인가?

1. 왜 WebSocket이 아닌? SSE인가?

우선 근본적인 질문부터 생각해 봅시다. 저희 쿠링의 AI서비스가 정말 실시간 채팅일까요?

 

저는 아니라 생각합니다.

 

대부분 사용자의 짧은 질문으로 시작되며, 실시간으로 쿠링과 대화를 하기보다는 원하는 질문을 한 후, 서버가 일방적으로 사용자에게 정보를 제공하고 종료하는 경우가 대부분일 거라 생각하였습니다.

 

즉 client는 송신보다는 주로 수신만 할 수 있는 상태이며, 저희 GPT에 적절한 상황이라 생각하였습니다.

또한, Web Socket과 달리 프로토콜 업데이트 같은 작업 없이 “일반” HTTP 요청을 사용하기 때문에 연결 오버헤드가 적기 때문입니다.

추가로 SSE는 가벼운 실시간 통신 기법이라, 메모리를 Web Socket보다는 적게 차지한다는 장점도 있습니다.

이러한 이유로 저희 쿠링은 SSE 방식을 선택하였습니다.

 

2. SSE(Server Sent-Event)란 무언인가?

SSE는 서버와 한번 연결을 맺고 나면 일정 시간 동안 서버에서 변경이 발생할 때마다 데이터를 전송하는, 즉 서버의 Event를 stream 하는 기술입니다.

 

일반적 HTTP 통신이 요청에 따른 데이터를 제공한 뒤 연결을 끊는 것과는 달리, 초기 서버 이벤트를 구독해 놓으면, 서버에서는 지정한 이벤트가 발생할 때마다 Client로 데이터를 보낼 수 있습니다.

 

서버에서 클라이언트로 text message를 보내는 기술이며 HTTP의 persistent connections을 기반으로 하는 HTML5 표준 기술입니다.

 

우선 SSE의 동작 방식은 다음과 같습니다.

 

1) Client: SSE Subscribe 요청

우선 클라이언트에서 서버의 이벤트를 구독하기 위한 요청을 보내야 합니다.

GET /api/v2/ai/messages?question={질문들} HTTP/1.1
Accept: text/event-stream
Cache-Control: no-cache

이벤트의 미디어 타입은 text/event-stream이 표준으로 정해져 있습니다.

이벤트는 캐싱하지 않으며 지속적 연결을 사용하셔야 합니다!

(HTTP 1.1에서는 기본적으로 지속 연결을 사용합니다).

 

2) Server: Subscription에 대한 응답

HTTP/1.1 200
Content-Type: text/event-stream;
Transfer-Encoding: chunked

응답의 미디어 타입은 text/event-stream입니다. 이때 Transfer-Encoding 헤더의 값을 chuncked로 설정합니다. 서버는 동적으로 생성된 콘텐츠를 스트리밍 하기 때문에 본문의 크기를 미리 알 수 없기 때문입니다.

 

3) Server: 이벤트 전달

클라이언트에서 subscribe를 하고 나면 서버는 해당 클라이언트에게 비동기적으로 데이터를 전송할 수 있습니다.

이때 Flux<String>을 통하여 전달하는 방식을 선택하였습니다. 실제 bot과 대화하듯 실시간적으로 한 글자 씩 빠르게 보여주고 싶었기 때문입니다.

 

데이터는 UTF-8로 인코딩 된 텍스트 데이터만 가능합니다(바이너리 데이터는 전송 불가능).

 

서로 다른 이벤트는 줄 바꿈 문자 두 개(\n\n)로 구분되며 각각의 이벤트는 한 개 이상의 {data: value} 필드로 구성되며 이들은 줄 바꿈 문자 하나로 구분됩니다.

 

고민 1-3) 어떤 VectorDB를 사용할 것 인가?

현 LLM 모델들이 각광을 받으면서 여러 vector db들이 경쟁구도를 갖고 있다.

이중에 뭐를 사용해야, 우리 쿠링에게 적합할까?

https://www.datacamp.com/blog/the-top-5-vector-databases

몇 가지 기준을 잡아봤는데, 다음과 같았다.

  1. open source일 것 (즉, 무료여야 한다)
  2. EC2상에 직접적으로 docker로 실행할 것 이기 때문에, 가능한 가벼울 것
  3. 조작이 매우 쉽고, 배우기에 어렵지 않은 것
  4. 태생이 VectorDB일 것 (일부 Redis,  PostgreSQL과 같이 태생이 vectorDB가 아니지만 vectorDB를 위해 변화한 제품은 선택하기 싫었다, 개발된 목적 자체가 VectorDB인 것을 선택하고 싶었다.)

이렇게 선별하고 나니 chroma, milvus 정도로 범위를 줄일 수 있었으며, 그중 "경량화"를 강조하는 ChromaDB를 쿠링의 vectorDB로 선택하게 되었다.

 

또한 추가적으로 multi-thread 기반의 동시 write를 지원하기도 하고, SQLite3 기반의 file 저장이라 내가 생각하는 "경량화"에 부합한다고 생각했다. 파일기반이라 공지 vector들을 백업하기도 수월할 것이라 생각하였다.

 

고민 1-4) SQLite는 성능적으로 부족하지 않을까?

우리의 ChromaDB는 Docker로 실행되며, EC2 인스턴스와 Volume을 공유하여 EC2 스토리지에 직접적으로 저장된다.

별도의 VectorDB 서비스를 유료로 사용할 금전적 자원이 부족했기 때문에, 최선의 선택이었다고 생각한다.

 

또한, EC2에 직접 설치하고 저장하다 보니 외부의 DB를 사용하는 것보다 성능적으로 더 빠를 수 있다 생각하였고, 다음 글에서 이를 확인할 수 있었다.

 

35% Faster Than The Filesystem

Some other SQL database engines advise developers to store blobs in separate files and then store the filename in the database. In that case, where the database must first be consulted to find the filename before opening and reading the file, simply storin

sqlite.org

 

SQLite 데이터베이스에서 콘텐츠를 읽는 것이, 디스크에서 직접 읽는 것보다 약 5배 더 빨리 읽을 수 있음을 알게 되었다.

즉, RDB같이 외부 디스크에서 읽어오는 케이스보다 더 빠르게 read가 가능하며, 이는 vecetorDB의 생명인 유사도검색 후 결과를 반환할 때 더 빨리 전달해 줄 수 있음을 알게 되었다.

 

고민 1-5) text 전처리시 어떤 Metadata가 필요할까?

원하는 대상 pdf, txt, 또는 문자열 등을 embedding 한 후에 그대로 바로 저장해야 할까?

추가 metadata로 뭘 추가해 줘야 활용도가 높을까?

 

  1. 제목
  2. article-id값
  3. 날짜

 

이중 날짜 값이 우리 공지시스템에는 중요한 metadata인데,

그 이유는 만약 2024년도에 "올해 2학기 수강신청을 알려줘!"라고 질문했다고 가정해 보자.

이때 서버에 23년도의 데이터와 24년도의 데이터가 둘 다 있다면, 두 문서 모두 유사도 검색에서 검색될 수 있으며, 자칫 23년도의 정보를 제공해 줄 수도 있다.

 

따라서 검색 시 연도를 filtering조건으로 추가해 주면 좋을 것 같다 생각하였다.

 

즉, 다음과 같이 2개의 과정으로 나누어지는데,

1. 올해 연도 기준으로 먼저 유사도 검색을 수행한 후, 반환된 값이 있다면 반환한다.

2. 만약 반환된 값이 없다면 이때 작년을 포함한 이전 연도에서 유사도를 검색한다.

 

2. 구현

구현 과정 자체보다는, 위 고민들이 더 값진 고민이었다 생각합니다. 구현에 대한 설명은 간단하게만 작성하도록 하겠습니다.

 

2-1) 공지 Embedding 과정

기본적인 시스템 전체 구성은 다음과 같다.

  1. 학교 공지를 url로 접근하여 text를 모두 scrap 한다
  2. scrap 한 text를 분할 과정에서 TextTokenSpliter를 통하여 분할한다
  3. 분할된 text를 embedding 한다.
  4. embedding 된 정보를 ChromaDB에 저장한다.

https://medium.com/@minji.sql/문서-전처리와-임베딩의-중요성-rag-프로젝트-성공하기-97ae34e879b4

 

2-2) 사용자 질문 처리 과정

서버의 서비스 코드는 다음과 같다.

@UseCase
@RequiredArgsConstructor
public class RAGQueryService implements RAGQueryUseCase {

    private final QueryVectorStorePort vectorStorePort;
    private final QueryAiModelPort ragChatModel;
    private final RAGEventPort ragEventPort;

    @Value("classpath:/ai/prompts/rag-prompt-template.st")
    private Resource ragPromptTemplate;
    private PromptTemplate promptTemplate;

    @Override
    public Flux<String> askAiModel(String question, String id) {
        try {
            ragEventPort.userDecreaseQuestionCountEvent(id);
            Prompt completePrompt = buildCompletePrompt(question);
            return ragChatModel.call(completePrompt);
        } catch (InvalidStateException e) {
            final String separator = "";
            return Flux.fromArray(e.getErrorCode().getMessage().split(separator));
        }
    }
    
    // 일부 생략
}
  1. 서비스에서 사용자의 남은 질문 횟수를 확인한 후
  2. 사전에 준비된 prompt template를 기반으로, 사용자의 질문과 유사검색을 통해 확인한 문장을 기반으로 완성된 prompt를 생성한다
  3. 해당 prompt로 AI model에게 물어본다, 쿠링의 경우 gpt-4o-mini 모델에 물어보게 된다.

이렇게 반환된 정보를 사용자에게 Flux<String>을 통하여 실시간으로 반환해 주게 된다.

 

또한 눈여겨볼 점으로 예외처리 부분이 있는데, 보통 REST방식의 API를 구현할 때는 예외를 던지면 ExceptionHandler를 통해 catch 하여 다른 응답으로 변환하여 반환해 줬지만, GPT의 경우 Service자체에서 예외를 catch 하여 에러 메시지를 직접 반환해 주도록 하였다.

 

이러한 구현의 이유에는, 어찌 됐든 사용자에게 응답이 나가기 때문에 client로 하여금 서버의 응답이 예외인지? 정상인지? 구분하지 않도록 하고 싶었던 점이 크다. 따라서 응답 상태코드도 모두 200 OK로 반환되게 된다.

 

3. 추가로, 오픈소스에 기여하기

이번 feature 구현을 하면서 서버 개발자로서 LLM시대에 LLM모델 자체를 이해하지는 못해도, 적절하게 사용하는 과정에 대하여 공부할 수 있었다.

 

서버 개발자가 나아갈 방향은 LLM모델 자체에 대한 공부가 아닌, 활용에 초점을 두어야 한다 생각하는데, 이번 기회에 이에 부합하는 학습을 하게 되었다.

 

또한 아직 정식 출시하지 않은 Spring AI를 공식문서 만으로 학습하면서, 나 스스로의 "학습과정" 자체가 발전하였으며

더 나아가 open source에 직접 기여해 보는 경험도 하게 되어 정말 뿌듯하였다. 다음은 내가 직접 수정한 Issue들이다. 

 

- Add additional tests for Document metadata and `TextTokenSplitter`

https://github.com/spring-projects/spring-ai/pull/934

 

- Fix `MilvusVectorStore` max allowed dimension size to 32768

https://github.com/spring-projects/spring-ai/pull/1008

 

요즘 틈틈이 Spring AI의 github에서 해결할만한 Issue들을 탐색하며 즐겁게 살아가고 있습니다!!

 

댓글