실시간 반응형 추천 개발 일지 #2. 벡터 검색, 그리고 숨겨진 요구사항과 기술 도입 의사 결정을 다루는 방법

5 hours ago 1

"실시간 반응형 추천 개발 일지 #1. 프로젝트 소개" 편을 읽고 2편을 기다려주신 여러분, 반갑습니다. (아직 안 읽으셨다면 읽고 오셔도 됩니다. 여기서 기다리고 있을 테니까요.)

오늘은 1편에 이어 실시간 행동 이력을 활용한 실시간 반응형 추천 시스템 의 개발에 대해 더 깊이 들어가 보도록 하겠습니다.

제 소개를 드려야겠네요. 저는 우아한형제들 추천프로덕트팀에서 AI/ML, Data Engineer를 하고 있는 정현입니다. 저는 1편 작성자인 김정헌 님처럼 추천 프로덕트를 개발하기 위한 대부분의 영역을 다루지만, 주로 다루는 영역은 MLOPS, Inference Serving 입니다. 즉, 트래픽을 받는 서비스와 시스템을 만드는 일이지요. 아이디어를 프로덕션 트래픽을 받는 서비스로 만드는 과정에서의 제 접근 방법에 대해 이야기해 볼게요. 특히, 프로젝트 요구 사항을 어떻게 기술 컴포넌트로 정의하는지, 그리고 컴포넌트에 대한 새로운 기술을 도입할 때는 어떻게 접근하는지에 대해 설명드리려고 해요.

1편에서는요

2편의 내용을 다루기 앞서, 1편에서 다뤘던 내용에 대해 간략하게 정리해 보겠습니다.

1편에서는 새롭게 도입된 실시간 반응형 추천 시스템기존의 시스템과 어떻게 다른지 비교하는 것으로 시작하여, 어떤 컴포넌트가 새롭게 도입되었는지, 어느 정도 성능 향상이 있었는지를 다루었습니다. 아마도, 실시간 반응형 추천 시스템의 놀라운 성능 향상(CTR 23% 상승, 노출 대비 주문전환율 40.24% 상승)과 실시간 유저의 관심사를 추천에 반영할 수 있다는 장점 때문에 1편의 내용에 관심을 많이 가져주셨을 거라 생각합니다.

그런데, 다른 회사의 시스템에 대한 글을 읽어봐도 지금 내가 다루어야 하는 시스템에 어떻게 적용할지 모르겠다면, 혹은 적용할 수 없다면, 많이 아쉽죠. 그래서 이 글에서는 요구사항에서부터 1편에서 보여드린 완성된 버전까지 "어떻게 고민하고 결정했을까"의 관점에서 설명해 보려고 합니다.

1편에서 설명드린 시스템에서 사용된 컴포넌트는 아래 3개와 같았습니다.

  • 실시간 행동 이력 스트리밍
  • 인코더 모델 학습 및 임베딩 추출
  • 벡터 유사도 검색

그런데 여기서 궁금한 점이 생깁니다.
이 컴포넌트들을 어떻게 정의하고, 프로덕션에서 서비스할 수 있는 기술 스택으로 선정하고 검증할 수 있었을까요?

2편에서는요

2편, 지금 읽고 계신 이 글에서는 아래 질문의 답을 찾아가면서, 실제 개발 과정에서 요구되는 내용들을 짚어보도록 하겠습니다.

  • 프로젝트의 요구 사항은 무엇이었는가?
  • 숨겨진 요구사항에서 기술적인 문제로 환원된 사항은?
  • 어떻게 도입 컴포넌트 후보를 선정하고, 평가하며 결정할 수 있었나?

또한 실제 사례 연구로써 벡터 검색 컴포넌트를 어떻게 도입하고 결정해가는지를 함께 알아보도록 하겠습니다.

프로젝트에서는 원했다. 숨겨진 요구사항을..

어떻게 하면 짜장면을 먹고 싶은 사용자에게 중식 가게를, 아이스 아메리카노가 갑자기 당기는 사용자에게 카페를 추천해 줄 수 있을까요?
우리는 사용자의 관심사를 실시간으로 파악하고, 이 관심사에 맞춰 추천을 제공하는 시스템을 만들어보기로 했습니다. 그리고 이를 위해 세 가지 새로운 컴포넌트를 개발했습니다.

1편에서 가져온 문장입니다. 아마도 프로젝트를 가장 간단하게 설명한 문장일 것 같아요. 서비스에서 요구한 내용을 기술적인 문제로 환원하고 기술적인 문제를 잘 풂으로써 실 서비스의 문제를 해결하는 것이 우리 엔지니어의 업무일 겁니다. 그러려면 요구사항에서 정말로 필요한 것이 무엇인지, 놓치고 있는 것은 없는지를 확인해야 합니다.

프로젝트의 요구사항이 문장으로 드러난 내용은 아래와 같습니다.

  • 사용자의 현재 관심사를 실시간으로 파악한다.
  • 관심사에 맞춰서 추천을 제공한다.

드러나지 않은 내용에 대해서도 살펴볼까요? 구체적으로 질문을 던져봅니다.

  • 사용자의 현재 관심사라는 건 뭘까요? 🤔
    • 사용자의 현재 관심사를 정의하는 여러 가지 방법이 있겠지만, 우리는 여기에서 사용자의 현재 행동 이력, 그중에서도 현재 사용자가 입력한 검색 키워드클릭한 가게implicit feedback(사용자의 취향을 간접 유추할 수 있는 반응)이라고 생각했습니다.
  • 관심사에 맞춰서 추천을 제공한다는 건 뭘까요? 🤔
    • ‘관심사에 맞춰서’라는 것과 ‘추천을 제공한다’는 두 가지로 볼 수 있어요.
    • 이 프로젝트의 추천은 가게를 대상으로 합니다. 그러니까 사용자의 관심사에 맞는 가게를 추천한다는 이야기입니다.
    • 위에서 정의한 관심사와 가게를 비교할 수 있어야 합니다. 따라서, 우리는 모델에서 생성된 행동이력과 가게에 대한 임베딩을 가지고 유사도를 계산하여 순서를 매길 수 있습니다.
  • 가게 추천은요? 전국 어디에 있든 관심사에 맞으면 될까요? 🤔
    • 우리 서비스는 "문 앞으로 배달되는 일상의 행복"이라는 비전을 가지고 있어요! 즉, 사용자에게 배달 가능한 위치의 가게여야만 합니다.
  • 사용자의 위치는 언제 알 수 있죠? 🤔
    • 사용자의 위치는 추천을 조회하는 시점! 에서만 알 수 있습니다. 즉, 우리는 사용자가 추천을 조회하는 시점에서 결과를 생성해야 해요.

이를 정리하여 프로젝트의 요구 사항으로 만들면 아래와 같습니다.


  • 사용자가 추천을 조회하는 시점에
    • 사용자의 관심사를 최대한 빨리 얻어낸다. (1)
    • 사용자의 현재 위치 기반으로 배달 가능한 가게 목록을 최대한 빨리 얻어낸다. (2)
    • 사용자의 관심사와 가게의 유사도를 최대한 빨리 비교할 수 있도록 준비해야 한다. (3)
    • 사용자의 관심사에 가장 잘 맞는 순서로 최대한 빨리 정렬한다. (4)

모든 요구사항에 똑같은 표현이 들어가 있는 걸 눈치채셨을까요?

바로 "최대한 빨리"입니다.

음식이 식지 않고 빠르게 고객에게 배달되길 원하는 것처럼 저희도 추천을 최대한 빠르게 전달드려야 합니다. 이 시스템의 이름이 "실시간 반응형"인 이유도 여기에 있습니다. 특히나, 미리 준비할 수 없고 고객이 추천을 조회하는 시점에서만 얻을 수 있는 데이터를 기반으로 추천을 만들어서 서빙하는 것은 가장 어려워요. 런타임에만 얻을 수 있는 정보를 기반으로 하는 추천 서빙 시나리오에서 프로덕션 트래픽을 받는 서비스를 만들 때 검토할 사항은 여러 가지에요. 더 다양한 추천/AI 서빙 시나리오와 주안점, 그리고 제가 어떻게 우아한형제들에서 사용하는 추천/ML 서빙 API 플랫폼을 구축했는지가 궁금하신 분들은 2023 우아콘에서 제가 발표했던 영상 링크를 참고하세요!

📌WOOWACON 2023: 여기, 주문하신 ‘예측’ 나왔습니다: 추천/ML에서 ‘예측’을 서빙한다는 것에 대하여

기술적인 문제로의 환원

이제 프로젝트 요구 사항을 기반으로 컴포넌트를 정의해 보겠습니다.

  • 주요 컴포넌트
    • 실시간 행동 이력 스트리밍
      • 사용자의 관심사를 최대한 빨리 얻어낸다. (1)
    • 좌표 반경 검색
      • 사용자의 현재 위치 기반으로 배달 가능한 가게 목록을 최대한 빨리 얻어낸다. (2)
    • 인코더 모델 학습 및 임베딩 추출
      • 사용자의 관심사와 가게의 유사도를 최대한 빨리 비교할 수 있도록 준비해야 한다. (3)
    • 벡터 유사도 검색
      • 사용자의 관심사에 가장 잘 맞는 순서로 최대한 빨리 정렬한다. (4)

어라? 1편에서 봤던 것보다 하나가 추가된 것 같네요?
우리 요구 사항에서 본 것처럼 "관심사에 맞는 가게" 에서 "관심사에 맞는 배달 가능한 가게"를 찾아야 합니다. 따라서 현재 위치 기준으로 배달이 가능한지를 알아내려면 좌표 기반 반경 검색이 꼭 필요한데요. 특히 조금 이따가 살펴볼 벡터 유사도 검색은, 일반적인 벡터 유사도 검색과는 다른 요구사항을 지니는데 이때 필터 요소로 사용됩니다.

컴포넌트: 도입 후보 선정 및 실험 설계

자, 이제 컴포넌트를 정의했으니 개발을 시작해야 합니다. 저 모든 컴포넌트를 자세히 설명드리고 싶지만, 그럼 저도 여러분도 이 자리에 오래 머물러야 할 테니, 엔지니어링 부분에서 가장 어려웠던 점만 설명하겠습니다. 이 시스템에서 엔지니어링의 핵심이자 도전적인 영역은 모델이 생성한 임베딩을 활용하여 벡터 유사도 검색을 수행하고, 추천 가게의 리스트로 빠르게 제공하는 부분입니다.

다시 정리하면, 다음과 같습니다.

  • 사용자의 실시간 관심사를 얻었다고 가정
  • 좋은 모델을 통해 관심사와 가게를 임베딩 공간에 잘 표현해두었다는 가정
  • 어떻게 빨리 사용자의 관심사에 맞는 가게를 찾아서 잘 맞는 순서대로 정렬한 다음에 전달할 것인가

구현하려면 어떤 기술이 필요하고, 가능한 옵션 중에서 어떤 기술 스택을 골라야 할까요?

일단 사내 위키를 뒤져볼까요? 앗, 이전에 도입한 사례가 하나도 없네요. 😮

스택 오버플로우랑 구글 검색에서 나온 기술 블로그들을 찾아봐야 할까요? 그런데 우리 케이스랑 딱 맞는 사례는 없는 것 같기도 하네요. 😞

벡터 유사도 검색을 우아한형제들 최초로 사용해야 했기에 참고할 만한 좋은 자료가 많이 부족했습니다. 가끔 이렇게 새로운 기술 도입을 해야 하는 경우에, 어떻게 의사 결정을 해야 할지 어떤 것들이 중요한지 고민을 많이 해보셨을 겁니다. 이때, 제가 중요하게 생각하는 점은 아래와 같습니다.


  • 내가 풀려고 하는 문제는 이 기술의 어떤 부분을 사용해야 하는가?
  • 우리는 (일반적인 상황과) 무엇이 다른가?
  • 내가 중요하게 생각하는 점은? (개발 생산성, 확장성, 운영 가능성, 비용.. 등등)

벡터 유사도 검색이 필요한 이유

다시 한번, 우리가 풀고자 하는 문제를 생각해 봅시다.

모델에서 나온 결과인 임베딩이 잘 저장되어 있을 때, 우리는 최대한 빠르게 고객의 위치에서 배달 가능한 가게들과 사용자의 현재 관심사와의 유사도를 계산하여 랭킹으로 제공해야 합니다. 임베딩을 검색하고 정렬한다는 점에서 벡터 유사도 검색(VSS, Vector Similarity Search) 이라는 기술이 필요합니다.
벡터 유사도 검색을 하는 방법에는 Exact-KNNANN이 있습니다. Exact-KNN(k-Nearest Neighbor)은 임베딩 벡터 공간 내의 좌표 간 거리를 정확하게 계산하는 것입니다. ANN(Approximate Nearest Neighbor, 근사 최근접 이웃)의 경우가 주로 더 많이 쓰이는데요. ANN은 recall(재현율)을 합리적으로 희생하면서도 검색 성능을 올리는 알고리즘입니다. IVFFlat이나 HNSW같은 알고리즘이 그 예죠.
실제로 서비스를 개발하던 당시에는 LLM, RAG에서 벡터 유사도 검색이 사용되다 보니, Atlas MongoDB나 Redis Enterprise 등 여러 서비스에서 벡터 유사도 검색을 경쟁적으로 오픈하고 홍보하던 시기였습니다. 관련 예제들도 공식 문서에서 볼 수 있었습니다. 성능이 좋은 알고리즘과 예제 코드까지 있네요! 그럼 그대로 사용해 볼까요?

벡터 유사도 검색: 우리는 무엇이 다른가?

우리 문제는 대부분 문제들이 그러하듯이 한 번에 해결이 되지는 않습니다.

무엇이 문제였을까요? 자, 예를 들어, 배달 가능한 가게들이 2000개 이상 넉넉하게 있고, 추천 가게를 최소 1000개를 제공하고 싶다고 해보겠습니다. 관심사 임베딩과 가게 임베딩을 가지고 HNSW나 IVFFlat 알고리즘을 사용해서 limit을 1000으로 놓고 결과를 얻었습니다. 다소 recall을 희생했지만, 계산 시간을 단축하면서 찾은 "현재의 사용자의 관심사에 유사한 가게들"이니 이해해 줄 만 합니다. 문제는, 이 1000개의 목록이 "현재의 사용자 위치에 배달 가능한가?"에 대한 답이 아니라는 데 있습니다. 입력 임베딩에는 우리 모델의 생성물인 관심사와 가게 임베딩만 있다 보니, ANN 알고리즘을 사용해 얻은 결과가 현재 사용자의 위치로 배달 가능한 가게인지 알 수 없습니다.
이쯤에서 아래처럼 생각하실지 모르겠습니다.

목록을 넉넉하게 2000개를 뽑고, 배달 가능한지 보면 되지 않을까요?

그렇게 간단한 문제는 아닙니다. 흔히 이 문제는 post filter 문제라고 부르는데요. ANN을 사용해 얻은 결과에서 우리가 원하는 조건 필터를 적용해 최종 엔트리를 확정 짓는 방법입니다. 문제는 이 post filter로 얼마나 남는지 그때그때 알 수가 없습니다. 정말 운이 나쁘다면 서울에 있는 사용자를 위해 3000개를 뽑았는데, 3000개가 전부 제주도에 있어서 제공 가능한 가게가 0개가 될 가능성도 있습니다. 즉, 결과 보장성이 떨어지는 이슈가 있습니다.

그렇기 때문에, 우리는 post filter가 아닌 방법으로 접근해야 합니다. pre filter라고 부르는 방법인데요. 먼저 조건 필터를 통해 우리가 원하는 대상을 축소한 다음에 후보군에 대해 벡터 유사도 검색을 통해 랭킹을 지정하는 것을 의미합니다.

그럼 뭐가 문제죠? 해결됐네요!

문제는 ANN 알고리즘, 위에서 말씀드린 HNSW, IVFFlat 등은 흔히 말하는 미리 인덱스를 빌드 해놓고, 검색 시점에서는 인덱스를 통해 성능을 향상시키는 방식의 알고리즘입니다. 이를 인덱스 빌드 "static" 시점과 검색 "runtime" 시점이라고 부르겠습니다. 우리가 앞에서 본 것처럼 현재 사용자의 위치를 기준으로 먼저 검색 대상 후보군을 좁힌 다음에 이 후보군에 대해서만 벡터 유사도 검색을 진행하면 static 시점에 만든 인덱스를 활용할 수 없습니다.

HNSW 알고리즘에서 보는 pre filter의 문제

ANN 중 대표적인 HNSW 알고리즘을 기준으로 설명해 보겠습니다. HNSW는 일종의 그래프를 활용하여 가까운 검색 대상을 효율적으로 찾는 알고리즘입니다. 모든 연결 그래프를 다 탐색하면 시간이 걸리니, 미리 검색에 사용할 밀도를 낮춘 그래프를 계층 레이어를 기반으로 static 시점에 만들어 둡니다. (데이터 갱신이 없다는 가정하에, 이 작업은 1회성이고, 이 인덱스를 빌드하는데도 역시 오버헤드가 존재합니다.) 검색 시점에서는 이 레이어를 사용하여 가까운 노드로 이동하고 계층 레이어를 이동하는 방식입니다.

hnsw 알고리즘
(출처: hnsw 알고리즘 설명, Pinecone)

위 참고 그림에서 기준으로 보면 노란색 query vector를 기준으로 nearest neighbor를 구하기 위해 맨 위 레이어의 파란색 entry point로부터 시작하여 계층 레이어를 타고 맨 아래에서는 쿼리 벡터 근처에 있는 노드를 탐색할 수 있습니다.

구현 방식은 라이브러리에 따라 다르지만, 대체로 확률 기반 스킵 리스트를 사용하여 랜덤으로 계층 레이어를 구성하고 인덱스로 생성합니다.
문제는 검색 시점에 pre filter로 좁힌 대상들에 대한 인덱스가 미리 준비되어 있지 않다는 점입니다. 사용자의 위치는 사용자의 조회 시점에서야 비로소 고정되며, 배달 가능 권역은 사용자의 위치 기반으로 좌표 반경을 기준으로 정해집니다. 따라서 우리는 이 runtime 시점에 주어진 좌표를 기준으로 반경 어느 대상의 인덱스를 미리 준비해야 할지 알 수 없습니다. 그렇다고 모든 가능한 후보 군들만 따로 포함된 인덱스를 미리 생성할 수는 없습니다. 이것이 pre filter 문제의 핵심입니다.

다시 돌아와서, 정리해보겠습니다.

ANN의 성능 개선은 대체로, 검색 전에 데이터를 기준으로 미리 인덱스를 만들고, 검색 시점에 인덱스를 사용하는 데서 나옵니다. 우리의 pre filter 문제는 좁혀둔 후보군에 대응하는 인덱스가 없기 때문에 ANN을 사용할 수 없습니다.

📌 참고로, 이렇게 미리 빌드한 인덱스를 검색 시점에서 조건 필터링을 스코어로 사용하여 검색하는 일종의 하이브리드 알고리즘이 개발 당시 pgvector 0.6.0 proposal 상태였습니다. HQANN 이라는 알고리즘인데요. 아직 2025.01 기준 구현되지 않은 것으로 보입니다만, 관심 있는 분들을 위해 공개된 논문 링크를 남겨둡니다. HQANN: (Efficient and Robust Similarity Search for Hybrid Queries with Structured and Unstructured Constraints)

기술 후보군 선택과 실험 설계

자 이제, 기술과 우리의 특수한 상황의 이해를 마쳤습니다.
문제 조건을 만족하는 후보군들을 고르고, 그중에서 어떤 것을 선정할지 고민하는 과정이 남았습니다.

후보군 선택과 검증을 위한 실험을 2차례 진행했습니다.
먼저, 개발 가능성과 기능 검증을 위한 소위 "찍먹" 단계의 실험을 진행했고, 이후 실제 프로덕션 운영을 감안하여 선정한 후보군들에 대한 성능 평가 실험을 진행했습니다. 각 단계에서 중요하게 생각했던 요소들에 대해서는 각 실험 섹션에서 설명하겠습니다.

자, 이제 제가 진행한 실험에 대해 설명드릴 텐데요. 중요하게 먼저 짚고 넘어가야 할 것이 있습니다. 이 실험에 대한 설명은 어떤 특정 소프트웨어가 더 강점이 있다는 것을 알려드리려는 것이 아닙니다. 설명드리고자 하는 바는, 후보군들 중 우리의 주안점을 만족하면서 문제를 잘 해결할 수 있는 것이 무엇이었는지를 검증하는 과정을 보여드리는 데 있습니다. 개발 당시와 현재 상황이 다를 수 있음을 감안해 주시길 바랍니다.

1차 실험

2023년 6월경에 실시한 1차 실험에서는 벡터 검색을 실제로 어떻게 동작시키는가와 플랫폼 구축을 어떻게 할 수 있는가에 집중했습니다. 기본적으로 이미 사용하고 있었던 Atlas MongoDB 외에는 OSS(Open Source Software)를 기반으로 하고 K8S 위에 올릴 수 있는 대상을 위주로 살펴보았습니다. 이 단계에서의 실험은 각 플랫폼이 어떻게 돌아가는지에 대한 파악, 코드 개발 / 기능에 대한 검증, 플랫폼 운영 등에 대해 검증해 보려고 했습니다. 중요한 벡터 검색 자체의 경우에는 조건절이 들어가지 않는 순수 벡터 검색과 조건 필터링이 들어가는 하이브리드 검색 2가지로 분류하여 코드를 작성해 보았습니다.

1차 실험에서 선정한 후보군은 아래와 같았습니다.

Pinecone과 같은 전문 벡터 검색 솔루션 등이 후보에 없었는가 같은 질문을 하실 분이 있으실지도 모르겠습니다. 저희 프로젝트 요구 사항 외에도, 시스템 개발 / 확장 가능성 / 운영, 유지 보수의 측면에서도 고려를 했습니다. 사내에 도입되어 있지 않거나 관리 주체가 없는 상황일 때는 fully-managed service를 고려하기 쉽지만, 신규 비용 계약을 검토해야 하는 등의 이슈가 있습니다. 이에 먼저 기존 도입된 솔루션과 OSS를 빠르게 사용해서 운영 가능한 솔루션이 있는지 먼저 검증해 보고, 없다면 그때 가서 신규 fully-managed service를 고려해 보자는 것이 당시의 생각이었습니다.

1차 실험 구축

각 후보군에 대해서는 아래와 같이 진행했습니다.

  • 설치
  • 데이터 인덱싱
  • 검색 (순수 벡터 검색, 하이브리드 조건 검색)
  • 결과 예제
  • 기타 사항

Milvus 실험
(출처: 실험 당시 작성했던 실험 관련 문서 목차)

후보군들에 대한 코드 스니펫을 전부 보여드리며 설명드리고 싶지만, 이미 실험 진행 시기와 현재의 시간 경과로 인해 많은 변경이 있을 것으로 보입니다. 이 중 일부 예만 보여드리겠습니다.

대상 데이터는 팀 내 데이터 과학자가 만든 배민스토어 상품 관련 임베딩을 사용했고 스니펫과 타입은 아래와 같았습니다.

product_id vector_representation product_name headquarter_id headquarter_name 0 64524 [1.1706082, -0.15787698, 0.6167851, 0.97479737... [2+1] 콜라제로500ml 17 배민마트24 1 39351 [0.9947525, 0.33829537, 0.868921, 0.3935619, 1... [2+1] 밤80g(S) 17 배민마트24 2 34514 [1.0548345, 0.14422302, 0.7457907, 0.3638356, ... [1+1] 사이다제로500ml 17 배민마트24 3 62758 [2.6520822, -1.4434108, 1.1016191, 0.20771386,... [1+1] 제로청포도석류355ml 17 배민마트24 4 35036 [2.7884235, -1.5786803, -1.4717882, -0.0563466... 감자칩어니언1700(20) 17 배민마트24 필드명 타입 예시 비고
product_id str 64524
vector_representation list[float] [1.1706082, -0.15787698, 0.6167851, 0.97479737… dim=50, 데이터 정규화되어 있지 않음.
product_name str [2+1] 콜라제로500ml
headquarter_id str 17
headquarter_name str 배민마트24

1차 실험에서는 플랫폼 구축과 운영 가능성, 인덱스 하의 벡터만을 사용한 검색과 조건 필터를 사용한 검색의 구현 가능성 만을 보기 위함이기 때문에 코드가 다소 단순합니다. 위에 첨부된 실험 관련 목차 스크린 샷처럼 크게 인덱스 생성, 데이터 입력, 검색 파트로 나눌 수 있습니다.
예를 들어 Milvus 파트의 코드를 살펴보겠습니다.

인덱스 생성과 데이터 입력은 아래와 같습니다.

from pymilvus import ( connections, utility, FieldSchema, CollectionSchema, DataType, Collection, ) import pandas as pd df = pd.read_pickle('df_prod_vec_hq.pkl') print(f'# of df : {len(df.index)}') print('connecting milvus') connections.connect('default', host='localhost', port='19530') coll_name = 'baemin_store' has = utility.has_collection(coll_name) print(f'collection baeminstore exists, drop collection') utility.drop_collection(coll_name) fieldname_vector = 'vector_representation' # data schema fields = [ FieldSchema(name='product_id', dtype=DataType.VARCHAR, is_primary=True, auto_id=False, max_length=10), FieldSchema(name=fieldname_vector, dtype=DataType.FLOAT_VECTOR, dim=50), FieldSchema(name='product_name', dtype=DataType.VARCHAR, max_length=100), FieldSchema(name='headquarter_id', dtype=DataType.VARCHAR, max_length=10), FieldSchema(name='headquarter_name', dtype=DataType.VARCHAR, max_length=100), ] schema = CollectionSchema(fields, 'baemin_store item') baemin_store_milvus = Collection(coll_name, schema, consistency_level='Strong') entities = [ df['product_id'].values.tolist(), df[fieldname_vector].values.tolist(), df['product_name'].values.tolist(), df['headquarter_id'].values.tolist(), df['headquarter_name'].values.tolist(), ] insert_result = baemin_store_milvus.insert(entities) baemin_store_milvus.flush() print(f'# entities in milvus inserted: {insert_result}') print(f'# entities in milvus : {baemin_store_milvus.num_entities}') # creat index print('create IVF_FLAT index') index = { 'index_type': 'IVF_FLAT', 'metric_type': 'L2', 'params': {'nlist': 128} } baemin_store_milvus.create_index(fieldname_vector, index) print('start loading') baemin_store_milvus.load()

검색 관련해서는 아래처럼 할 수 있어요.

# 벡터 검색 search_params = { 'metric_type': 'L2', 'params': {'nprobe': 10}, } result = baemin_store_milvus.search(vector_to_search, fieldname_vector, search_params, limit=10, output_fields=['product_name', 'headquarter_name']) for hits in result: for hit in hits: print(f'hit: {hit}, product name: {hit.entity.get("product_name")}, hq name: {hit.entity.get("headquarter_name")}') # 하이브리드 검색 result = baemin_store_milvus.search(vector_to_search, fieldname_vector, search_params, expr="headquarter_id == \"17\"", limit=10, output_fields=['product_name', 'headquarter_name']) for hits in result: for hit in hits: print(f'hit: {hit}, product name: {hit.entity.get("product_name")}, hq name: {hit.entity.get("headquarter_name")}')

저희 팀은 당시에, Atlas MongoDB를 이미 운영 중이었습니다.
Atlas MongoDB에서 지원하는 Atlas Search 쪽 코드를 살펴보겠습니다.

Atlas Search는 웹 UI에서 인덱스를 설정합니다.

Atlas MongoDB(Atlas Search) 설정
(출처: 실험 당시 작성한 사내 위키 문서에서 발췌)

그럼 준비된 인덱스에 데이터를 넣어보겠습니다.

from pymongo import MongoClient, InsertOne import pandas as pd mg_url = 'MG_URL FOR CONNECTION' mg = MongoClient(mg_url) db = mg['test_baeminstore'] coll_item = db['item'] df = pd.read_pickle('df_prod_vec_hq.pkl') df_dict = df.to_dict(orient='records') batch = [] for d in df_dict: d['vector_representation'] = d['vector_representation'].tolist() batch.append(InsertOne(d)) coll_item.bulk_write(batch)

검색은 아래처럼 할 수 있습니다.

ret = coll_item.aggregate([ { '$search': { 'index': 'idx_baeminstore_item', 'knnBeta': { 'vector': vec, 'path': 'vector_representation', 'filter': None, 'k': 10, } }, }, { '$project': { 'product_id': True, 'product_name': True, 'headquarter_id': True, 'headquarter_name': True, 'score': {'$meta': 'searchScore'}, '_id': False } } ]) for r in list(ret): print(r)

위 코드에 대한 설명을 달아두었습니다.

mongodb의 pipeline / aggregate 쿼리를 사용함. 크게 3페이즈로 나눌 수 있음. $search 사용 인덱스, 입력 벡터, 검색 필드, k 수치 지정 filter $search 내에 필터링 텍스트를 통해 하이브리드 서치를 사용 가능. 필요 없을 시 None 지정 $project $search phase에서 hit된 결과들을 어떻게 출력할지에 대한 옵션 필요시 필드에 True, 필요 없을 시 False로 제외 가능 score 필드는 위에서 볼 수 있듯 '$meta': 'searchScore' 로 지정할 수 있음.

이렇게, 각 시스템의 비슷한 기능의 구현을 비교하면서 개발 가능성과 운영 가능성을 검증해 봅니다. 이 때는 운영용 코드를 작성하기보다는 시스템에 익숙해지는 데 주력합니다. 예를 들면, 저희팀에서는 대량의 데이터를 다룰 때 Spark를 사용해서 처리하는데, 위 코드에서는 이런 복잡한 영역을 단순화하거나 생략합니다. 최대한 대상 플랫폼 자체의 기능만을 검증해 보는데 집중하는 것이죠. 특히, 잘 모르는 시스템이라면 공식 문서 등을 통해 구조 분석을 하기도 합니다. 아래처럼요.

milvus 구조 분석
(출처: 실험 당시 작성한 Milvus 구조 분석 문서)

자, 이제 대략적으로 어떻게 개발하면 될지, 운영은 가능할지 검증이 끝났습니다.
이 실험의 결론을 저는 아래처럼 정리했습니다.

1차 실험 정리
(출처: 실험 당시 작성한 결과 문서)

여러 요소 들을 고민해 본 결과 실제 성능 테스트인 2차 실험에서 검증해 볼 후보는 Atlas MongoDB(Atlas Search)와 OpenSearch로 좁혀졌습니다.

2차 실험

2차 실험은 1차 실험과 검증 성격이 다릅니다. 실제 워크로드 하에서 부하를 주고, 성능을 검증해보는데 그 목적이 있습니다.

2차 실험을 진행하려던 2023년 10월경에 Amazon RDS for PostgreSQL(이하 RDS)에서 pgvector extension 0.5.0(HNSW 지원, 거리 함수 성능 개선 등)을 지원하게 되었다는 뉴스를 전해 듣게 되었습니다. RDS는 전사 DB를 관리하는 클라우드스토리지개발팀에서 운영을 지원해 주고, pgvector는 벡터 검색 시나리오에서 많이 사용되고 있는 기술입니다. 클라우드스토리지개발팀과의 협업으로 2차 실험에서 이것도 같이 검증하기로 합니다.

다행히(?) 1차 실험에서 살아남은 Atlas MongoDB는 managed 서비스이고, OpenSearch 도 AWS managed 버전으로 사용할 수 있습니다. 새롭게 후보로 추가된 RDS 역시 managed 서비스라고 볼 수 있습니다. 이로써 2차 실험 후보군들은 운영, 유지 보수에 대해 유리한 환경을 가지고 있습니다.

우리의 최종 요구 사항에는 pre filter(현재 위치로 배달 가능한 가게들로 좁혀진 후보군)를 사용해야 하기 때문에 이 부분도 반영해서 코드를 작성해야 합니다. 앞서 HNSW 알고리즘의 예를 들어 pre filter가 있는 경우, ANN을 사용하기 어렵다고 했는데요. 이 부분에 대해서도 각 후보군의 구현과 동작이 그러한지 실제로 확인이 필요합니다.

opensearch knn 설명
(출처: OpenSearch 공식 문서)

지면 관계상, 후보 중 하나인 OpenSearch의 공식 문서를 기준으로 설명 드리겠습니다.
OpenSearch의 공식 문서의 내용을 간단하게 설명하면, OpenSearch Efficient k-NN filtering은 pre filter 적용 여부에 따라 ANN / Exact-KNN을 선택하는 알고리즘입니다. 우리의 시나리오를 기반으로 따라가보면 Exact-KNN이 선택되는 것을 알 수 있습니다. 가능하다면, 이런 식으로 다른 후보군에 대해서도 내부 동작에 대해 조사합니다.

실제 동작에서 우리의 가설이 맞는지 실험을 통해 확인해볼 수 있습니다.

exact knn 설명
(출처: 실험 당시 작성된 결과 위키 페이지에서 발췌)

Atlas MongoDB의 경우 pre filter 코드 스니펫은 아래와 같습니다.

filtered_shop = {"shop_no": {"$in": target_filtered}} ret = coll.aggregate( [ { "$vectorSearch": { "index": "idx_embedding", "path": "embedding", "queryVector": target_vector, "filter": filtered_shop, "limit": limit, "numCandidates": k, } }, {"$project": {"shop_no": True, "score": {"$meta": "vectorSearchScore"}, "_id": False}}, ] ).to_list(limit)

OpenSearch는 아래처럼 검색할 수 있습니다.

query = { "size": limit, "query": { "knn": { fieldname: { "vector": target_vector, "k": k, "filter": {"terms": {"shop_no": target_filtered}}, } } }, } ret = es.search(body=query, index=indexname, size=limit) ret = ret["hits"]["hits"] ret = [{"shop_no": x["_source"]["shop_no"], "score": x["_score"]} for x in ret]

RDS의 경우에는 아래처럼 검색할 수 있습니다. (pgvector extension 설치와 테이블 생성 부분은 생략했습니다.)
pgvector에서 지원하는 거리 메트릭 타입마다의 연산자를 써서 SQL을 작성하고 쿼리를 실행하면 됩니다.

# 거리 메트릭 정의, enum 값은 pgvector 거리 연산자 class BaeminVSSDistanceType(str, Enum): L2 = "<->" IP = "<#>" COSINE = "<=>" def _make_vss_query( target_col_name: str, embedding_col_name: str, distance_type: BaeminVSSDistanceType, embedding: list[float], table: str, candidates_str: str, part_date: str, limit: int, ) -> str: stmt = "" if distance_type == BaeminVSSDistanceType.COSINE or distance_type == BaeminVSSDistanceType.L2: stmt = f""" ( SELECT {target_col_name} , 1 - ({embedding_col_name} {distance_type.value} '{str(embedding)}') AS similarity FROM {table} WHERE {target_col_name} IN({candidates_str}) AND part_date = '{part_date}' ORDER BY similarity DESC LIMIT {limit} ) """ elif distance_type == BaeminVSSDistanceType.IP: stmt = f""" ( SELECT {target_col_name} , ({embedding_col_name} {distance_type.value} '{str(embedding)}') * -1 AS similarity FROM {table} WHERE {target_col_name} IN({candidates_str}) AND part_date = '{part_date}' ORDER BY similarity DESC LIMIT {limit} ) """ return stmt # target_filtered에 python list로 pre filter된 집합이 들어있어서 이부분을 SQL로 바꾸는 부분 target_filtered_str: str = str(target_filtered)[1:-1] # embedding은 유저의 행동이력 (키워드, 혹은 클릭 가게) 에 대한 임베딩 (구현 생략) # 키워드, 가게에 대한 임베딩은 벡터 검색 외에도 해당하는 아이디 값으로 검색했을 때 쿼리 벡터를 꺼낼 수 있도록 데이터가 적재되어 있습니다. # 아래 get_actions_embed 함수를 통해 특정 유저의 실시간 행동 이력을 1개 얻어오고 행동 이력의 타입에 따라 임베딩을 꺼냅니다. # 이를 우리는 쿼리 벡터로 사용할 것입니다. embedding = await get_actions_embed(mem_no) # ... 중략 ... # SQL query 생성 stmt = _make_vss_query( target_col_name, embedding_col_name, distance_type, embedding, table, candidates_str, part_date, limit, ) async with pg.connection() as conn: async with conn.cursor() as cur: await cur.execute(stmt) ret = await cur.fetchmany(limit)

자, 이제 성능 측정에 대한 준비가 끝났습니다.

2차 실험 구축

2차 실험에서는 실제 워크로드 기반으로 부하를 측정합니다.
이를 위해 입력에 필요한 값들이 있는데요. 테스트의 단순화를 위해 아래처럼 가정합니다.

  • 우리는 사용자의 위치에 따라 좌표 반경 검색을 수행하고, 이를 통해 후보 가게군을 알고 있다고 가정합니다.
  • 우리는 실시간 파이프라인에서 사용자의 행동이력을 가져와서 입력으로 넣어줄 수 있다고 가정합니다.

이렇게 API에 필요한 데이터를 준비하고 각 구현된 API(Atlas MongoDB, OpenSearch, RDS)에서 응답시간을 확인해봅니다. API 구현은 python 기반의 추천/ML 서빙 API 플랫폼을 기반으로 하며, 같은 로직에 저장소만 다르게 구현했습니다. (추천/ML 서빙 API 플랫폼에 대한 더 자세한 사항은 위에 첨부한 WOOWACON 2023 발표 자료를 참고해주세요!)

비부하 테스트

부하 테스트를 실행하기 전에 어느 정도의 응답 속도를 보여주는지 비부하 상태에서 호출을 수행해 봅니다. 이를 통해, 우리가 어느 정도의 성능 기대치를 가지고 부하테스트를 진행해야 하는지에 대한 대략적인 감을 잡을 수 있습니다. 우리의 상황에서는 ANN을 쓸 수 없지만, 그래도 Exact-KNN과 얼마나 성능 차이가 나는지도 알아볼 겸, 필터가 없는 단순 검색 ANN과 Exact-KNN에 대한 응답시간에 대해 기록했습니다.

2차 비부하 테스트 결과 정리
(출처: 실험 당시 작성된 결과 위키 페이지에서 발췌, 참고: A는 Atlas MongoDB, O는 OpenSearch, P는 Pgvector(RDS))

부하 테스트

부하 테스트는 저희가 사용하는 Locust를 기준으로 진행했습니다.

부하를 넣으면서 초당 처리량(RPS), 응답 시간, 대상 시스템의 부하 정도가 어떻게 바뀌는지를 확인해야 합니다.

Atlas MongoDB부터 보겠습니다.
2차 실험 atlas mongodb a
(출처: 실험 당시 작성된 결과 위키 페이지에서 발췌)

부하를 추가하면서 RPS 증가 여부와 대상 시스템의 부하를 살펴봅니다. 위 이미지는 Locust에서 보여주는 성능 측정 그래프와 Atlas MongoDB 모니터링 이미지인데요. CPU가 100% 가 되는 시점에서는 API의 처리량은 늘어나지 않고 응답 시간이 느려지는 것을 볼 수 있습니다.

그 다음은 OpenSearch 입니다.
2차 실험 opensearch a
(출처: 실험 당시 작성된 결과 위키 페이지에서 발췌)

이 경우에도 OpenSearch의 부하가 최대치에 달하면서 API의 요청 성능이 더 나아지지 않는 포인트를 확인할 수 있습니다.

마지막으로 RDS입니다.
2차 실험 pgvector
(출처: 실험 당시 작성된 결과 위키 페이지에서 발췌)

당시 실험 문서에 써둔 문구에서도 확인 가능한데요. 다른 시스템에 비해 처리 가능한 요청량이 많고, 더 많은 요청을 처리하면서도 RDS 시스템에 여유가 있습니다.

최종 리포트를 정리하면 아래와 같습니다. 아래 리포트에서는 초당 요청량, 각 퍼센타일 별 응답시간(latency), 응답 실패 여부 등을 확인합니다.

Atlas MongoDB
2차 실험 atlas mongodb b
(출처: 실험 당시 작성된 결과 위키 페이지에서 발췌)

OpenSearch
2차 실험 opensearch b
(출처: 실험 당시 작성된 결과 위키 페이지에서 발췌)

이 시점에는 부하를 견디지 못하고 500으로 요청 실패도 등장했습니다.

RDS
2차 실험 pgvector b
(출처: 실험 당시 작성된 결과 위키 페이지에서 발췌)

2차 실험에 대한 정리를 해보면, 저희 시나리오에서는 RDS가 가장 좋은 성능을 보여주는 것을 확인할 수 있었습니다. 초당 요청량이 높고, 실패 건이 없으며 각 퍼센타일별 응답시간 역시 다른 후보들 대비 더 짧은 것을 볼 수 있습니다.

이렇게 2차 실험인 부하 테스트를 통해 어느 정도 트래픽에 어느 정도 리소스를 투입할 수 있는지에 대한 감을 잡고 개발을 진행할 수 있었습니다.

간단한 성능 최적화 방법 (RDS)

여기까지 긴 여정을 함께해 주신 분들을 위해 성능 개선을 진행했던 건에 대해 간단히 말씀드리고 넘어가고자 합니다. 우리의 케이스를 보면 행동 이력 N건에 대해 각각의 임베딩을 쿼리 벡터삼아 후보군과의 벡터 검색을 N회 진행하고, 이를 전체적으로 정렬하는 과정이 있습니다. API에서 DB인 RDS에 쿼리를 여러 번 보내는 것보다 1번의 실행으로 처리할 수 있다면 더 이익일 것 같습니다. 이에 코드를 아래처럼 수정했습니다.

def _make_vss_query( group_id: int, target_col_name: str, embedding_col_name: str, distance_type: BaeminVSSDistanceType, embedding: list[float], table: str, candidates_str: str, part_date: str, limit: int, ) -> str: stmt = "" if distance_type == BaeminVSSDistanceType.COSINE or distance_type == BaeminVSSDistanceType.L2: stmt = f""" ( SELECT {group_id} , {target_col_name} , 1 - ({embedding_col_name} {distance_type.value} '{str(embedding)}') AS similarity FROM {table} WHERE {target_col_name} IN({candidates_str}) AND part_date = '{part_date}' ORDER BY similarity DESC LIMIT {limit} ) """ elif distance_type == BaeminVSSDistanceType.IP: stmt = f""" ( SELECT {group_id} , {target_col_name} , ({embedding_col_name} {distance_type.value} '{str(embedding)}') * -1 AS similarity FROM {table} WHERE {target_col_name} IN({candidates_str}) AND part_date = '{part_date}' ORDER BY similarity DESC LIMIT {limit} ) """ return stmt query_list = [ _make_vss_query( i, target_col_name, embedding_col_name, distance_type, embedding, table, candidates_str, part_date, limit, ) for i, embedding in enumerate(embeddings) ] stmt = " UNION ALL ".join(query_list) # ... 중략 ... # stmt 실행 pg_ret = [BaeminPGVSSResult(group_id=x[0], item=x[1], score=x[2]) for x in ret] def _group_by_id(self, vss_results: list[BaeminPGVSSResult]) -> list[list[BaeminPGVSSResult]]: ret: dict[int, list[BaeminPGVSSResult]] = {} for vss_result in vss_results: if vss_result.group_id not in ret: ret[vss_result.group_id] = [] ret[vss_result.group_id].append(vss_result) return [v for _, v in sorted(ret.items(), key=lambda kv: kv[0])] # _group_by_id를 통해 최종 결과를 그룹별로 정렬 multiple_reranked = _group_by_id(pg_ret) # Reciprocal Rank Fusion(RRF) 방식을 사용하여 여러 VSS 결과를 하나로 통합합니다. (구현 생략) shop_results = _get_reciprocal_rank_fusion_scores(multiple_reranked)

간단하게 설명드리면, 여러 쿼리를 union으로 묶어서 한 번에 실행하고 결과를 파싱해 groupby 하는 코드입니다.
이 내용을 생각할 때는, 괜찮은 방법인데? 라고 생각했는데요. 실제로 2024년에 참여했던 AWS Summit Seoul에서도 관련 내용을 꿀팁으로 전수해 주는 세션을 듣게 되었습니다. 만약 이와 같은 방식으로 구현해야 한다면 고려해 보시길 바랍니다.

자, 이제 드디어 결론까지 왔습니다.

지금까지 우리가 알아본 것을 정리하면 아래와 같습니다.

  • 프로젝트의 요구 사항은 무엇이었는가?
  • 숨겨진 요구사항에서 기술적인 문제로 환원된 사항은?
  • 어떻게 도입 컴포넌트 후보를 선정하고, 평가하며 결정할 수 있었나?

위 질문들을 실시간 반응형 추천의 요구사항에서 출발해서 기술 컴포넌트로 정의하고, 이에 맞는 후보군 선정과 검증 단계까지 진행해 보았습니다.

추가적으로, 벡터 유사도 검색이라는 기술을 요구사항에 맞춰서 연구하고 검증해 나가는 구체적인 사례도 함께 알아보았습니다.

벡터 검색에 대한 이해와 우리에게 필요한 부분에 대해 범위를 좁혀 나가고, 그 과정에서 새로운 기술을 도입하는데 고민해야 할 부분들인 운영 구축, 부하 응답 성능 등도 같이 알아보았습니다. 실험과 기술, 코드 관련된 내용은 시간이 지나고 버전이 바뀌면서 금방 outdated 되는 것들이 많겠지만, 벡터 검색을 처음부터 도입해야 하는 분들에게 개략적인 이해와 도움이 될 수 있기를 기대합니다.

마지막으로, 실시간 반응형 추천에 대한 이해를 도와드릴 수 있도록 WOOWACON 2024 추천프로덕트팀 홍보 부스에서 전시되었던 그림을 보여드리고 마무리하려고 합니다.

감사합니다!

우아콘 부스
우아콘 포스터

Read Entire Article