booklista tech blog

booklista のエンジニアリングに関する情報を公開しています。

Qdrant を商用のレコメンドシステムで使ってみた

こんにちは。株式会社ブックリスタでレコメンドシステムの開発をしている佐伯です。 今回は、電子書籍ストアのレコメンドシステムに Qdrant を導入した事例を紹介します。 ここで得られたレコメンドシステムに向いたポイントや、苦労した点についても共有します。

この記事で伝えたいこと

  • レコメンドシステムを従来のバッチ処理型から、Qdrant を活用したリアルタイム型へ移行したこと
  • バッチ処理でシステム負荷や費用が課題になっており、それが改善できたこと
  • Qdrant はレコメンドに向いた機能があり、開発もしやすく、短期間で商用環境の切り替えが実現したこと

Qdrant とは

Qdrant(クワドラントと読みます) は Rust で開発されたオープンソースのベクトルデータベースです。 高次元ベクトルに対する効率的な検索や大規模データセットへの対応が可能なスケーラビリティを備えています。

Qdrant は開発も始めやすく、サーバーの Docker イメージを起動しクライアントの SDK をインストールすれば、ローカルでの試作を始めることが可能です。 公式ドキュメントも充実しており、ベクトルの更新や検索などの主要な操作がコード例とともに記載されているため、開発中の疑問が概ね解消できます。

さらに、Qdrant Cloud というマネジメントサービスを使うことでスムーズに次のステップに進めることができます。 テスト環境は無料で作成できますし、商用環境用のクラスタも簡単に立ち上げることができるため、短時間でリリースが可能になります。

Qdrant 導入の背景

今回話題にするレコメンドシステムは、電子書籍ストアへ来訪したユーザーにおすすめの本を提案するものです。

Qdrant を導入する前は、ユーザーごとの推薦リストをバッチ処理で事前に作成する方式でした。 私たちのレコメンドシステムでは、推薦リストを 1 ユーザー 1 リストではなく、商品の条件ごとに複数の推薦リストを作成します。 例えば、「無料のコミック」や「新刊のラノベ」など、条件に応じた推薦リストをユーザーごとに準備します。

導入前の構成図

この方式は、推薦時に RDB からレコードを取り出すだけで良いため、レスポンスが高速というメリットがあります。 一方で、以下のような点がデメリットとして挙げられます。

  • ストアを利用するユーザーや、リストを作成する商品の条件が多くなると、推薦リストの数が急激に増加する
  • 来訪しないユーザーにも推薦リストを作ることになるため、無駄な処理が発生する
  • 直近のユーザーの行動や新刊を推論結果に含めるため、バッチ処理を頻度高く行う必要がある

当初はこの方式でも問題のない規模で稼働できていましたが、時間が経つにつれ、以下の問題が顕在化してきました。

  • 推薦リストの件数が増加し、RDB への書き込み時間が数時間以上に及ぶ
  • 推薦リストの書き込み処理が集中して、RDB の負荷が高まり、書き込みエラーが発生する
  • 書き込みデータ量の増加に伴い、インフラコスト(主に AWS Aurora にかかるストレージ容量や IO 単価)が高騰化する

これらの課題の解決のためにバッチ方式のままいくつかの代替案を検討しましたが、いずれもコストや処理時間の問題を解決できませんでした。 そのため、推論リストの作成をリアルタイムに行う方針へ転換しました。 リアルタイム処理にすることで、大量の推薦リストの作成やそれに伴うエラーやコストの問題は解決します。 一方で、性能上のメリットを失うことになるため、それがクリアできる移行先を検討することになります。

Qdrant を選んだ理由

弊社のレコメンドエンジンでは、バッチでの推論の時点でも内部的に Faiss でのベクトル検索を使用して、近しいベクトルから推薦リストを作成する仕組みをとっていました。 リアルタイム処理に切り替える際も同じデータを利用できるベクトル検索データベースを利用するのが自然なアプローチになるため、これらの製品の中から切り替え先を選定しています。

対象の製品としては、Qdrant、Elasticsearch、Pinecone、または Faiss で自前のクラスタを組むなどの候補がありました。 これらに対しての検討は以下の観点で行っています。

  • 推薦商品の条件をリアルタイムに絞り込めるフィルタリング機構があること
  • チームメンバーが少ないため、なるべくサーバーの運用負担が少ないこと
  • レスポンス性能が SLO を満たすこと

後述しますが、Qdrant にはメタデータによる直感的に理解しやすいフィルタリングがあります。 サーバー運用についても、Qdrant Cloud を使用すれば、AWS などでインフラ構築をせずに運用できます。 また、検索時のレスポンスタイムが懸念でしたが、事前に簡単なベンチマークを行い十分に速いレスポンスが得られました。 想定スペックで試算をした場合のランニングコストも問題ないことが確認できたため、切り替えを進めることにしています。

なお、最初に検証した Qdrant で条件を満たせることがわかったため、私たちの検討では他の製品の検討は行なっていません。 さらに検討を進めれば、他の製品でも条件を満たすものはありえます。

導入後のシステム構成

Qdrant を導入した後の構成はこの図のようになります。 RDB に推薦リストを登録する処理を廃止し、学習した結果のベクトルデータを直接 Qdrant へアップロードしています。 レコメンド API への問い合わせも、ユーザー ID と商品条件を用いて Qdrant サーバーからリアルタイムに推薦リストを取得する方式にしています。

導入後の構成図

レコメンドシステムで使ってみて良かったところ

ここからは商用のレコメンドシステムで使用してみて、特にマッチしていた点を説明します。

商品属性での柔軟なフィルタリングができる

前述した通り、レコメンドエンジンから推薦する商品には所定の条件に応じた絞り込みを行います。 この絞り込みをベクトル検索の結果に対して行うと、レスポンスに必要な商品件数に対して不足するケースが発生します。 特に商品の条件が特殊な場合は、推薦上位の商品に対象がほとんどないケースもあり得ます。 バッチによる事前推論の時は、これらの条件で事前に絞り込んだ推薦リストを作ることで対処していましたが、リアルタイムで処理する場合は推薦商品リストの作成時に絞り込みを行う必要があります。

Qdrant では、検索したい条件をペイロードとしてベクトルに関連付けてアップロードしておき、ベクトル検索と同時にペイロードによるフィルタリングが可能です。 これにより、条件を満たす推薦リストを一度のリクエストで必要件数取得できます。

ペイロードによるフィルタリングは SDK に用意されているクラスで条件を合成する形で実装できます。 条件にはテキスト一致だけでなく値や日付の比較や範囲指定できるので、よく検索に使われる条件はカバーできます。

フィルタリングのコードが直感的で、視認性が高いこともポイントです。 例えば、「1 週間以内に発売された無料のコミック」という条件であれば、検索時に以下のようなフィルター記述を付与することで実装できます。 (key の名称はペイロードのアップロード時に指定するものです)

from qdrant_client import models

models.Filter(
  must=[
    models.FieldCondition(
      key="sales_from", range=models.DatetimeRange(lte=now)
    ),
    models.FieldCondition(
      key="price", match=models.MatchValue(value=0)
    ),
    models.FieldCondition(
      key="genre", match=models.MatchValue(value='comic')
    ),
  ]
)

ペイロードの構造は自由度が高く、事前にスキーマを定義しておく必要もありません。 例えば、属性の追加や削除は随時行うことができるほか、ベクトルごとに保持する属性を変えることも可能です。 そのため、運用しながら柔軟に変更していくことができます。

ただし、ペイロードによるフィルタリングを高速に行うためにはインデックスを作成しておく必要があります。 また、大量にペイロードをアップロードすると Qdrant サーバーのメモリやストレージの使用量が増加するので、注意は必要です。

推薦のユースケースに合わせた検索方法が複数用意されている

Qdrant にはベクトルの集まりを表現するコレクションというデータ構造があります。 例えば類似の商品を探す時は、商品ベクトルを集めたコレクションから特定の商品に近いベクトルを探すことになります。 その際もいくつかの方法が取れます。

  • コレクションの中にある商品ベクトルの ID を 1 つ指定して、近いものを検索する (ある商品に近いものの検索)
  • コレクションの中にある商品ベクトルの ID を複数指定して、近いものを検索する (複数の商品群に近いものの検索)
  • ベクトルデータの配列を直接検索条件に渡して、近いものを検索する (コレクションにない商品の検索)

さらに、検索元のベクトルとして、他のコレクションのベクトルを指定できます。 私たちのレコメンドエンジンでは、商品のベクトルとは別にユーザーごとのベクトルを作成しておき Qdrant にアップロードしてあります。 ユーザーに対するおすすめ商品を検索する際には、ユーザーベクトルのコレクションから ID を指定することで、商品のコレクションの近似ベクトルを検索できます。 (ユーザーベクトルと商品ベクトルは同じ次元数である必要があります)

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")

client.query_points(
    collection_name="item_vector_collection",
    query=models.RecommendQuery(
        recommend=models.RecommendInput(
            positive=[100], # ここにユーザーベクトルのIDを指定する
        )
    ),
    lookup_from=models.LookupLocation(
        collection="user_vector_collection", vector="user_vector" # ここでユーザーベクトルのコレクションを指定
    ),
)

ベクトルのアップロード、インデックス作成が速く、データ切り替えが無停止で可能

Qdrant Cloud へのベクトルやペイロードのアップロードは当初時間がかかることを懸念していましたが、かなり短時間で完了します。 ベクトル数や次元数、ペイロードのデータ量にもよりますが、私たちの環境の場合、十数分ほどで完了しています。 当初のバッチ処理では、RDB のレコード作成で何時間もかかっていたことを考えると、これは大幅な改善です。

それとは別に、既存のベクトルデータを更新する場合も、Qdrant には便利な機能があります。 Qdrant でデータを更新する場合、ベクトルごとに更新データをアップロードしていく方式になります。 そのため更新中は一部のベクトルデータやペイロードのみ更新された状態になって、検索結果が予期しないものになります。 これが問題ないケースもありますが、私たちのシステムでは学習の度に全ベクトルデータを一括で差し替える方式にしているため、なるべく避けたいものでした。

Qdrant にはコレクションエイリアスという機能があり、この機能を使用すると上記の問題を解消できます。 コレクションエイリアスはアップロードしたコレクションに別名を割り当てるものです。 更新データを使用中のコレクションとは別にアップロードしておき、全て完了したらエイリアスを切り替えます。 こうすることで、中間状態の問題がなく、シームレスに更新後のデータを参照させることができます。 ベクトル検索をするクライアント側はエイリアス名を参照するため、クライアント側への影響もありません。

当初はデータ更新をハンドリングする仕組みを自前で実装する可能性を考慮していたのですが、この機能で必要がなくなりました。

苦労・妥協したポイント

一方で導入時に苦労したり、一部苦労した点もありました。

ペイロードのフィルター条件によっては速度が出ないケースがある

検索時のペイロードのフィルター条件によっては、検索レスポンスが遅くなるケースもありました。 基本的には、ペイロードのインデックスを作成する、カーディナリティが小さくならないようなペイロードを作成する、といった対応で検索速度は向上します。 ただし、一部のフィルター条件での検索速度がどうしても上がらないケースがあり、その箇所だけはバッチ推論方式を残す形で回避しています。

どのようなパターンでレスポンスタイムが悪化するかがはっきりとは分からなかったため、今回は使われる可能性のあるフィルター条件全てでベンチマークを実施して対応しています。 RDB のように、ノウハウが貯まれば注意すべきケースがわかっていくのですが、当初は主要なフィルターに対して個別に性能評価をした方が良さそうです。

スケールアップやスケールアウトはできるが、逆ができない

Qdrant Cloud のクラスターはサーバーのスペックやノード数などを Web コンソールで柔軟に変更可能です。 そのため、稼働後に負荷が高くなってきたらスペックをあげたり、ノード数を増やしたりして対応できます。 一方、スケールダウン・スケールインについては現状では対応しておらず、縮小するにはクラスターの再作成が必要になります。 クラスターの再作成は API で問い合わせる URL の変更と API 用のアクセスキーの変更が発生しますので、API を呼び出すシステムにも影響するものになります。

Qdrant ではレプリケーションやシャーディングをサポートしているため、無停止でのスケールインが難しい可能性はあります。 しかしながら、アクセス増加に対する一時的な増強は実運用上よく発生するもののため、対応して欲しいところです。

まとめと今後

以上が、商用のレコメンドシステムを Qdrant に切り替えた時の内容になります。 この切り替えは、稼働中のシステムのバックエンドをバッチ処理からリアルタイム処理へ大きく変えるものだったことに加え、エラーの発生やコストの増大など早期解決が必要な課題への対応を迫られたものでした。 しかしながら、Qdrant には商用のレコメンドシステムの実装に向いた機能が備わっており、性能上の問題もほぼ起こらなかったためスムーズに移行できました。 また、このブログ記事を書いているタイミングで切り替えから 4 ヶ月ほど経っていますが、サービスも安定しておりトラブルは全く起きていません。

Qdrant は現在も活発に更新されています。 私たちのシステムへの導入時点ではバージョン 1.9 だったのですが、今リリースノートを見ると 1.12.4 が最新になっていました。 その中には性能向上や新しい API の追加などあり、上手く活用すればさらにシステムの改善につなげることができますので、今後もウォッチしていきたいと考えています。