입력하는 대로 의미 검색

입력하는 대로 의미 검색 (Semantic Search As You Type)

사용자가 글자를 입력할 때마다 결과가 즉시 갱신되는 검색창, 거기에 의미(semantic) 기반까지 더하면 어떨까요? 이 글은 Qdrant의 recommend API와 prefix_cache 컬렉션을 활용해 밀리초 단위로 동작하는 시맨틱 검색 애스유타입(search-as-you-type) 데모를 Rust로 구현한 과정을 소개해요. JSON 코드와 벤치마크 숫자는 그대로 따라가 보시면 좋아요.

출처: 공식문서

Qdrant는 가장 빠른 벡터 검색 엔진 중 하나예요. 그래서 자랑할 데모를 찾던 중, 완전한 의미 검색 백엔드를 갖춘 검색-애스-유-타입(search-as-you-type) 상자를 만들어 보자는 아이디어가 떠올랐어요. 우리 웹사이트에는 이미 의미/키워드 하이브리드 검색이 있죠. 그런데 그건 Python으로 작성되어 있어 인터프리터 오버헤드가 좀 있어요. 당연히 Rust를 쓰면 얼마나 빨라질 수 있는지 보고 싶어졌습니다.

Qdrant는 자체적으로 임베딩을 하지 않으므로, 임베딩 모델을 결정해야 했어요. 이전 버전은 SentenceTransformers 패키지를 사용했는데, 이 패키지는 Bert 기반 All-MiniLM-L6-V2 모델을 사용해요. 이 모델은 실전에서 검증되었고 속도 면에서 괜찮은 결과를 보여줘서, 이 부분을 실험하지 않고 ONNX 버전을 가져와 서비스 안에서 실행했어요.

워크플로는 이렇게 생겼어요. 토큰화와 임베딩 후에 Qdrant에 /collections/site/points/search POST 요청을 보내는 겁니다. 전송하는 JSON은 다음과 같아요.

POST collections/site/points/search
{
  "vector": [-0.06716014,-0.056464013, ...(382 values omitted)],
  "limit": 5,
  "with_payload": true,
}

네트워크 왕복을 피하더라도 임베딩은 여전히 시간이 걸려요. 최적화에서 늘 그렇듯, 작업을 더 빨리 못 한다면 좋은 해결책은 작업 자체를 피하는 것이에요(우리 사장님께는 말하지 말아 주세요). 이건 흔한 접두사(prefix)를 미리 계산해 그 임베딩을 만든 다음 prefix_cache 컬렉션에 저장함으로써 달성할 수 있어요. 이제 recommend API 메서드가 임베딩을 전혀 하지 않고도 최적의 매칭을 찾을 수 있어요. 지금은 짧은 접두사(5글자 이하까지)를 사용하지만, 로그를 파싱해 가장 흔한 검색어를 얻어 나중에 캐시에 추가할 수도 있어요.

이걸 동작하게 만들려면 prefix_cache 컬렉션을 설정해야 하는데, 점들의 point_id를 접두사로, vector를 임베딩으로 사용하면 검색이나 인덱스 없이 룩업을 할 수 있어요. prefix_to_id 함수는 현재 PointIdu64 변형을 사용하는데, 이는 8바이트를 담을 수 있어 이 용도로 충분해요. 필요해지면 이름을 UUID로 인코딩하고 입력을 해싱하는 방법도 쓸 수 있고요. 우리는 모든 접두사가 8바이트 안에 들어간다는 걸 알기 때문에 지금은 이 방법을 쓰지 않기로 했어요.

recommend 엔드포인트는 search_points와 대략 비슷하게 동작하지만, 벡터를 검색하는 대신 Qdrant가 하나 이상의 점을 검색해요(검색 엔진이 결과에서 피하려는 부정 예시 점을 줄 수도 있어요). 이건 추천 엔진을 구동하기 위해 만들어진 것으로, 현재 점의 벡터를 Qdrant에 다시 보내 더 유사한 것을 찾는 왕복을 절약해 줘요. 그런데 Qdrant는 한 걸음 더 나아가 점을 룩업할 다른 컬렉션을 선택하게 해줍니다. 덕분에 prefix_cache 컬렉션을 사이트 데이터와 분리해 둘 수 있어요. 그래서 우리 경우에는 Qdrant가 먼저 prefix_cache에서 점을 룩업하고, 그 벡터를 가져와 캐시에서 미리 계산된 임베딩을 사용해 site 컬렉션에서 검색해요. API 엔드포인트는 다음 JSON을 POST로 /collections/site/points/recommend에 보내는 것을 기대합니다.

POST collections/site/points/recommend
{
  "positive": [1936024932],
  "limit": 5,
  "with_payload": true,
  "lookup_from": {
    "collection": "prefix_cache"
  }
}

이제 최고의 Rust 전통답게 엄청나게 빠른 시맨틱 검색을 갖추게 됐어요.

데모를 위해 우리 Qdrant 문서 웹사이트의 페이지 검색을 사용해 기존 Python 구현을 교체했어요. 빈말만 늘어놓지 않기 위해, 서로 다른 코드 경로를 실행하는 여러 질의를 보여주는 벤치마크를 준비했어요.

연산 자체는 네트워크보다 훨씬 빠르고, 네트워크는 가변성 때문에 대부분의 측정 가능한 차이를 묻어버리므로, Python과 Rust 서비스를 모두 로컬에서 벤치마킹했어요. 같은 AMD Ryzen 9 5900HX, 16GB RAM, Linux에서 두 버전을 모두 측정했어요. 표는 평균 시간과 오차 범위(ms 단위)를 보여줘요. 최대 천 개의 동시 요청까지만 측정했어요. 그 범위 안에서는 어느 서비스도 요청이 늘어도 느려지지 않았어요. 우리 서비스가 DDOS에 걸릴 것 같진 않아서 더 많은 부하로는 벤치마킹하지 않았습니다.

더 지체하지 않고 결과를 보여드릴게요.

query length Short Long
Python 🐍 16 ± 4 ms 16 ± 4 ms
Rust 🦀 1½ ± ½ ms 5 ± 1 ms

Rust 버전은 일관되게 Python 버전을 능가하며, 몇 글자 안 되는 질의에서도 시맨틱 검색을 제공해요. 접두사 캐시가 동작하면(짧은 질의의 경우) 시맨틱 검색은 Python 버전보다 10배 이상 빨라질 수도 있어요. 전반적인 속도 향상은 Python + FastAPI(그것도 이미 훌륭히 동작하지만)에 비해 Rust + Actix Web의 오버헤드가 상대적으로 낮기 때문이고, 임베딩에 SentenceTransformers 대신 ONNX Runtime을 사용하기 때문이기도 해요. 접두사 캐시는 임베딩 작업을 전혀 하지 않고 시맨틱 검색을 수행함으로써 Rust 버전에 진짜 큰 도움을 줍니다.

덧붙이자면, 여기 보이는 밀리초 차이는 사용자에게는 상대적으로 의미가 작을 수 있어요. 사용자의 지연 시간은 그 사이의 네트워크가 지배하거든요. 하지만 타이핑할 때는 밀리초 하나하나가 사용자 인식에 차이를 만들 수 있어요. 또한 검색-애스-유-타입은 일반 검색보다 3~5배 많은 부하를 만들므로 서비스는 더 많은 트래픽을 겪게 될 거예요. 요청당 시간이 줄어든다는 건 더 많은 요청을 처리할 수 있다는 뜻이에요.

임무 완료! 그런데 잠깐, 더 남았어요!

정확한 매칭과 제목 우선순위

결과 품질을 개선하기 위해 Qdrant가 병렬로 여러 검색을 수행하고, 서비스가 그 결과를 순서대로 합쳐 첫 번째 최적 매칭들을 가져올 수 있어요. 확장된 코드는 다음을 검색합니다.

  1. 제목의 텍스트 매칭
  2. 본문(문단 또는 목록)의 텍스트 매칭
  3. 제목의 의미 매칭
  4. 임의의 의미 매칭

이들은 위 순서대로 모아지고, 필요하면 중복을 제거합니다.

searchrecommend 요청을 보내는 대신, 각각 search/batchrecommend/batch 요청을 보낼 수도 있어요. 각각은 원하는 만큼의 search/recommend JSON 요청을 담는 "searches" 속성을 포함합니다.

POST collections/site/points/search/batch
{
  "searches": [
    {
      "vector": [-0.06716014,-0.056464013, ...],
      "filter": {
        "must": [
          { "key": "text", "match": { "text": <query> }},
          { "key": "tag", "match": { "any": ["h1", "h2", "h3"] }},
        ]
      }
      ...,
    },
    {
      "vector": [-0.06716014,-0.056464013, ...],
      "filter": {
        "must": [ { "key": "body", "match": { "text": <query> }} ]
      }
      ...,
    },
    {
      "vector": [-0.06716014,-0.056464013, ...],
      "filter": {
        "must": [ { "key": "tag", "match": { "any": ["h1", "h2", "h3"] }} ]
      }
      ...,
    },
    {
      "vector": [-0.06716014,-0.056464013, ...],
      ...,
    },
  ]
}

질의들이 배치 요청으로 처리되므로 추가적인 네트워크 오버헤드는 없고 계산 오버헤드도 아주 작지만, 결과는 많은 경우에 더 좋아져요.

유일하게 추가되는 복잡함은 결과 리스트를 평탄화하고 point ID로 중복을 제거하며 처음 5개를 가져오는 것이에요. 이제 마지막 문제가 하나 있어요. 질의가 recommend 코드 경로를 택할 만큼 짧은데 prefix_cache에는 없을 수 있어요. 그 경우 검색을 **순차적(sequentially)**으로 수행하면 서비스와 Qdrant 인스턴스 사이에 두 번의 왕복이 생겨요. 해결책은 동시에(concurrently) 두 요청을 시작하고, 성공한 첫 번째 비어 있지 않은 결과를 취하는 것이에요.

이것은 Qdrant 벡터 검색 엔진에 더 많은 부하를 뜻하지만, 이건 제한 요소가 아니에요. 많은 경우 관련 데이터가 이미 캐시에 있으므로 오버헤드는 수용 가능한 범위 안에 머물고, 접두사 캐시 미스의 경우 최대 지연 시간은 확실히 줄어듭니다.

코드는 Qdrant github에서 볼 수 있어요.

정리하면: Rust는 빠르고, recommend는 미리 계산된 임베딩을 쓰게 해주며, 배치 요청은 훌륭하고, 시맨틱 검색을 단 몇 밀리초 만에 할 수 있어요.

더 알아보기 (Learn more)