RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

ベクトル検索だけじゃ足りない?Qdrantで精度を高めるハイブリッド検索

はじめに

こんにちは。メールディーラーAI開発課のmarronです。エンジニアブログ初投稿となります。よろしくお願いします。

私が所属しているメールディーラーAI開発課では、主にメールディーラーに搭載されるAI機能の開発を担当しています。
現在は10月にリリース予定の回答自動生成エージェントの開発を進めています。
この機能を開発するにあたって、新たにベクトルDBを利用したナレッジの検索機能が必要となりました。
本記事では、ベクトルDBでの検索精度を上げるために導入したハイブリッド検索についてご紹介します。

ベクトルDBの選定

ベクトルDBとは

そもそも「ベクトルDBとはなんぞや」という方もいらっしゃると思いますので、簡単に説明させていただきます。
開発中の機能では、過去の対応履歴やFAQをもとに作成したナレッジから問い合わせの回答に必要となる情報を検索します。
ナレッジの検索には全文検索を利用したキーワード検索を用いても良いのですが、キーワード検索の場合は問い合わせに含まれるキーワードがナレッジに含まれるキーワードと少しでも異なると情報がヒットしません。
この問題を解決するために、ナレッジや問い合わせを数値ベクトル化してベクトルの近さによる検索を行います。この数値ベクトルは、文章全体がどういった意味を示しているかを多次元で表したものになります。
検索時はベクトル同士の距離を測ることで、距離が近い = 意味が似ているデータを見つけることができます。ベクトル化(埋め込み表現)については参考文献もご参照ください。
このベクトルデータを保存するのに利用するのがベクトルDBになります。ベクトルDBはベクトルデータを保存することに特化しており、膨大なベクトルデータ同士の距離計算を効率よく高速に行ってくれるものになります。

メールディーラーで採用したベクトルDB

メールディーラーでベクトルDBを利用するのは2回目なのですが、前回採用したChromaDBでは今回の機能を提供するにあたってパフォーマンス面で不安がありました。
そのため、別途ベクトルDB専用のサーバを構築することにしました。
利用するDBを選定するために以下のような条件を満たす製品を探すことにしました。

  • オンプレミス環境で動作すること
  • マルチテナント構成に対応できること
  • 複数台サーバを用いた負荷分散が行えること

この条件を満たす製品として「Milvus」「Qdrant」「PGVector」を候補として、パフォーマンスや使いやすさを比較しました。
結論としてはタイトルにもある通り、Qdrantを採用することにしました。理由は下記の通りです。

  • Rust製で、高速に検索が行えることを謳っている
  • Dockerコンテナだけでなく、単体バイナリとしても配布されており、オンプレミスのサーバに導入しやすい
  • マルチテナントでの運用方法がドキュメントに記載されており、インデックスの最適化方法まで記載されている
  • 簡単な設定で分散環境を構築することができる

利用するベクトルDBが決定したので、実際に利用してみます。

密ベクトルを用いた検索

Qdrantでの密ベクトル検索

最初に説明した文章全体の意味合いを示したベクトルデータを密ベクトル(Dense Vector)と呼びます。まずはこの密ベクトルを利用した検索を行ってみます。

ベクトルデータを投入するためにQdrant上にコレクションを準備します。
今回のサンプルコードはすべてPythonで記載しています。また、Qdrant公式のDockerイメージを利用して環境を構築しています。

from qdrant_client import QdrantClient, models

qdrant = QdrantClient(url="http://localhost:6333")
qdrant.create_collection(
    collection_name="dense_collection",
    vectors_config=models.VectorParams(size=1536, distance=models.Distance.COSINE)
)

sizeにはベクトルデータの次元数を指定します。今回はOpenAIのEmbeddings APIによるベクトル化を行い、モデルにtext-embedding-3-smallを利用するため、1536次元を指定しています。
distanceにはベクトルデータの距離計算に利用する方式を指定します。今回はテキストの類似度を調べるのに最適とされるコサイン類似度を指定しています。

コレクションが作成出来たら、検索対象のデータを保存します。今回はChatGPTを利用して、5つの猫種の特徴を20件ずつ文章にしてもらい、計100件のデータを投入しました。

アメリカンショートヘアは筋肉質である。
スコティッシュフォールドは耳が折れている猫である。
メインクーンは世界最大級の猫である。
シャムは社交的な猫である。
ペルシャは長毛の猫である。
...
from qdrant_client import QdrantClient, models
from openai import OpenAI

qdrant = QdrantClient("http://localhost:6333")
openai = OpenAI()

with open("cat.txt", "r", encoding="utf-8") as f:
    for idx, line in enumerate(f):
        line = line.strip()
        response = openai.embeddings.create(
            input=line,
            model="text-embedding-3-small"
        )
        vector = response.data[0].embedding
        qdrant.upsert(
            collection_name="dense_collection",
            points=[
                models.PointStruct(
                    id=idx,
                    vector=vector,
                    payload={"text": line}
                )
            ]
        )

Qdrantではベクトル以外のデータをpayloadという形式で保持します。今回はベクトル化対象の文章をpayloadに保持するようにしています。

準備が出来たので、投入したデータに対して検索を行ってみます。

from qdrant_client import QdrantClient, models
from openai import OpenAI

qdrant = QdrantClient("http://localhost:6333")
openai = OpenAI()

query = "穏やかな性格の猫"

response = openai.embeddings.create(
    input=query,
    model="text-embedding-3-small"
)
vector = response.data[0].embedding

search_result = qdrant.query_points(
    collection_name="dense_collection",
    query=vector,
    with_payload=True,
    limit=5
)

for point in search_result.points:
    print(f"Score: {point.score}, Text: {point.payload['text']}")

実行結果がこちらになります。

Score: 0.6045555, Text: メインクーンは落ち着いた雰囲気を持つ猫である。
Score: 0.59054977, Text: ペルシャは優雅な雰囲気を持つ猫である。
Score: 0.565565, Text: ペルシャは静かな生活を好む猫である。
Score: 0.55805016, Text: ペルシャは穏やかな性格である。
Score: 0.5490287, Text: スコティッシュフォールドは静かな環境を好む猫である。

無事似ている文章を検索することが出来ました。
このときのスコアはベクトルの類似度を示しており、コサイン類似度を利用した時は値が大きいものほど似ていることを示しています。

密ベクトル検索の欠点

密ベクトルを利用した検索では文章が似ているかどうかに着目して検索を行います。
このとき、あくまで類似度に着目して検索を行うため、全く同じキーワードを含んでいるかには着目していません。これが弱点となるパターンがあります。
例えば、「○○ではAボタンを決定として使う」と「××ではBボタンを決定として使う」というナレッジがあったとします。
このときに「○○ではどのボタンが決定ですか?」という問い合わせがあった場合に前者の情報を使いたいのにもかかわらず、後者の情報を取得する可能性があります。これでは回答文として利用できません。
そこでキーワード検索と同じ仕組みをベクトル検索でも行えるようにします。

疎ベクトルを用いた検索

疎ベクトルとは

キーワード検索を行うためには文章内にどのような単語が含まれているかを知る必要があります。そこで利用するのが疎ベクトル(Sparse Vector)になります。
密ベクトルではベクトル内の値が0以外であることが多いのですが、疎ベクトルでは単語の頻出度や決まった単語との類似度を示すため、ベクトル内の値が0であることが多いです。そのため、値がスカスカのベクトルという意味で疎ベクトルと呼ばれます。
疎ベクトル同士を比較することで単語ごとの頻出度を調べ、検索したいキーワードが多く含まれる文章を取り出すことが出来ます。

Qdrantでの疎ベクトル検索

Qdrantでは1つの文章に対して、密ベクトルと疎ベクトルの両方を保持することが出来ます。
ただし、既存のコレクションに対して、保持するベクトルの個数や形状を変更することができないため、新たにコレクションを作成します。

from qdrant_client import QdrantClient, models

qdrant = QdrantClient(url="http://localhost:6333")
qdrant.create_collection(
    collection_name="sparse_collection",
    vectors_config={"dense": models.VectorParams(size=1536, distance=models.Distance.COSINE)},
    sparse_vectors_config={"sparse": models.SparseVectorParams()}
)

次に密ベクトルと一緒に疎ベクトルを投入します。
密ベクトルはOpenAI APIを利用することで生成することが出来ますが、疎ベクトルを生成するAPIは提供されていません。 そのため、疎ベクトル化は自ら行う必要があります。
また、今回疎ベクトル化にはQdrantが提供しているライブラリを採用したのですが、このライブラリは日本語のトークン化(文章の単語を切り分ける処理)に対応していません。そのため、トークン化の処理を自前で実装する必要があります。
疎ベクトル化の処理は参考文献を元に実装しています。今回のサンプルプログラムではこの疎ベクトル化の処理については省略させていただきます。
密ベクトルのところで利用した100件のデータから作成した密ベクトルと疎ベクトルを投入します。

from qdrant_client import QdrantClient, models
from openai import OpenAI

qdrant = QdrantClient("http://localhost:6333")
openai = OpenAI()

# TextEmbedderの実装については省略
embedder = TextEmbedder()

with open("cat.txt", "r", encoding="utf-8") as f:
    for idx, line in enumerate(f):
        line = line.strip()
        response = openai.embeddings.create(
            input=line,
            model="text-embedding-3-small"
        )
        dense_vector = response.data[0].embedding
        sparse_vector = embedder.embed_query(query_text=line)
        qdrant.upsert(
            collection_name="sparse_collection",
            points=[
                models.PointStruct(
                    id=idx,
                    vector={
                        "dense": dense_vector,
                        "sparse": models.SparseVector(
                            indices=sparse_vector.indices.tolist(),
                            values=sparse_vector.values.tolist(),
                        )
                    },
                    payload={"text": line}
                )
            ]
        )

疎ベクトルだけを使って検索を行ってみます。

from qdrant_client import QdrantClient, models
from openai import OpenAI

qdrant = QdrantClient("http://localhost:6333")
openai = OpenAI()

query = "穏やかな性格の猫"

# TextEmbedderの実装については省略
embedder = TextEmbedder()
sparse_vector = embedder.embed_query(query_text=query)

search_result = qdrant.query_points(
    collection_name="sparse_collection",
    query=models.SparseVector(
        indices=sparse_vector.indices.tolist(),
        values=sparse_vector.values.tolist()
    ),
    using="sparse",
    with_payload=True,
    limit=5
)

for point in search_result.points:
    print(f"Score: {point.score}, Text: {point.payload['text']}")

実行結果がこちらになります。

Score: 2.0, Text: スコティッシュフォールドは穏やかな性格である。
Score: 2.0, Text: ペルシャは穏やかな性格である。
Score: 2.0, Text: アメリカンショートヘアは穏やかな性格である。
Score: 2.0, Text: メインクーンは穏やかな性格である。
Score: 1.0, Text: メインクーンは子どもと仲良くできる猫である。

単語単位で似ている文章が取り出せていることが分かります。
このときのスコアは似ている単語がどの程度文章に含まれているかを示しています。

両方の検索結果を組み合わせるハイブリッド検索

密ベクトルと疎ベクトルの両方を活用する

これで文章の類似度による検索とキーワードを利用した検索を行えるようになりました。
この2種類の検索方法は互いの弱点を補うものですので、検索結果を統合することで精度を上げることが出来ます。
問題はこの2つの検索結果をどうやって統合するかです。それぞれの検索結果を元に類似度が高い文章を取り出したいので、それぞれのスコア(順位)から再度スコア計算を行う必要があります。
この仕組みをハイブリッド検索と呼びます。ハイブリッド検索を簡単に行える方法をQdrantが提供していますので、実際に使ってみます。

Qdrantでのハイブリッド検索

Qdrantでハイブリッド検索を行う場合、まずprefetchという機能で先に密ベクトル、疎ベクトルのそれぞれの検索結果を作成しておきます。
その後、検索結果を統合するために検索クエリにfusionを指定します。このとき、統合する方法にはRRF手法とDBSF手法を利用できます。
RRF手法は検索結果の順位に基づいて新しいスコアを計算します。一方、DBSF手法は検索結果そのもののスコアを利用して新しいスコアを算出します。
RRF手法は順位ベースのため、異なる性質を持つベクトル(今回の場合は密ベクトルと疎ベクトル)を比較する際に有効です。そこで今回はRRF手法を採用しました。
逆に、同種のベクトルで内容が異なる複数の検索結果を統合する場合には、DBSF手法の方が適していると考えられます。手法の詳しい内容については参考文献をご参照ください。
では、ハイブリッド検索を実際に行ってみます。コレクションは疎ベクトルの時に作成したものを利用します。

from qdrant_client import QdrantClient, models
from openai import OpenAI

qdrant = QdrantClient("http://localhost:6333")
openai = OpenAI()

query = "穏やかな性格の猫"

response = openai.embeddings.create(
    input=query,
    model="text-embedding-3-small"
)
dense_vector = response.data[0].embedding

# TextEmbedderの実装については省略
embedder = TextEmbedder()
sparse_vector = embedder.embed_query(query_text=query)

search_result = qdrant.query_points(
    collection_name="sparse_collection",
    prefetch=[
        models.Prefetch(
            query=dense_vector,
            using="dense",
            limit=5
        ),
        models.Prefetch(
            query=models.SparseVector(
                indices=sparse_vector.indices.tolist(),
                values=sparse_vector.values.tolist()
            ),
            using="sparse",
            limit=5
        )
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    with_payload=True,
    limit=3
)

for point in search_result.points:
    print(f"Score: {point.score}, Text: {point.payload['text']}")

検索結果を統合するため、prefetchで取得する件数は最終的に必要な件数より大きくしておく必要があります。prefetchの取得件数が少ないと統合した際にどちらかのベクトルに偏ったデータも取得されてしまいます。 実行結果がこちらになります。

Score: 0.53333336, Text: ペルシャは穏やかな性格である。
Score: 0.5, Text: メインクーンは落ち着いた雰囲気を持つ猫である。
Score: 0.5, Text: スコティッシュフォールドは穏やかな性格である。

文章の類似度と単語単位での類似度を元に文章が取り出せています。
このときのスコアはそれぞれのベクトルでの順位を元に算出されたものとなります。

まとめ

Qdrantを利用したハイブリッド検索を活用することで、ナレッジの検索精度を向上させました。
みなさんもRAGのような文章検索を行う場面がありましたら、ぜひハイブリッド検索を利用した精度向上を行ってみてください。

参考文献

atmarkit.itmedia.co.jp

qdrant.tech

qiita.com

dev.classmethod.jp

Copyright © RAKUS Co., Ltd. All rights reserved.