QdrantとPythonによるリアルタイムセマンティック検索:ペイロードフィルタリングとベクトルインデックスの最適化

AI tutorial - IT technology blog
AI tutorial - IT technology blog

ベクトル検索におけるアプローチの比較

Elasticsearchなどの全文検索エンジンは、BM25スコアリングによる単語の完全一致やトークンの重複に依存しています。しかし、このモデルはユーザーが類語や同義語を用いた場合に機能しません。例えば、「ソフトウェアエンジニア向けの高コスパなノートPC」というクエリを投げても、トークンが一致しないため「プログラマー向け格安ノートPC」というタイトルの商品を見つけ出すことはできません。

密なベクトル埋め込み(Dense vector embeddings)は、こうした語彙の不一致(ボキャブラリーミスマッチ)を解消します。モデルはテキストを高次元空間上のポイントへとマッピングし、概念的な類似性を幾何学的な距離として表現します。しかし、文を384次元や1,536次元の浮動小数点数の配列に変換した後、それらを「どこで」「どのように」検索するかという選択が、トラフィック急増時にパイプラインがスケールするか、それとも停滞するかを決定づけます。

  • PostgreSQL + pgvector: すでにシステムでPostgresを運用している場合には実用的な選択肢です。リレーショナルテーブルに対してクエリを実行し、標準的なSQL内でコサイン類似度を計算できます。ただし、500万件を超える行に対してHNSWインデックスを構築するには数ギガバイト規模の専用メンテナンスワークメモリ(maintenance_work_mem)が必要となり、複雑なJOINとベクトル距離計算が同時に走るとレイテンシが跳ね上がるという課題があります。
  • 2段階の事後フィルタリング(Post-Filtering): 近似最近傍(ANN)インデックスに対して最初にクエリを実行し、メタデータの条件を満たさない結果を後から除外する構成です。このアプローチは選択性(セレクトビティ)が高い場合に破綻しやすくなります。例えばcategory = "electronics"の上位10件を取得したい場合でも、最近傍の95%がたまたま"clothing"であれば、数百件の候補を過剰取得(オーバーフェッチ)しない限り、目的の結果がほとんど得られません。
  • Qdrant(シングルステージフィルタリング): Rustで記述されたQdrantは、HNSWグラフの探索プロセスにペイロードメタデータを直接組み込んでいます。ベクトル検索が完了した後に候補を間引くのではなく、グラフノードをたどる各ステップでブールフィルターを評価します。条件に一致しないベクトルは距離計算のキューに一切入りません。

アーキテクチャのトレードオフ概要

どのベクトル検索戦略にも、レイテンシ、メモリ消費量、そしてインフラの複雑さの間で明確なトレードオフが存在します。

1. PostgreSQL + pgvector

  • メリット: 新たなインフラを追加する必要がありません。既存のビジネステーブルに対する使い慣れたリレーショナル結合、標準的なACID特性、行レベルロックをそのまま維持できます。
  • デメリット: インデックス構築時のメモリ消費量が急増します。大規模なマルチテナント環境では、クエリプランナーがフィルターの選択性を見誤ると、フィルターとベクトルを組み合わせた検索が低速なシーケンシャルスキャンにフォールバックすることがよくあります。

2. ナイーブな事後フィルタリング

  • メリット: FAISSやAnnoyといったスタンドアロンのANNライブラリの上に極めて簡単に構築できます。
  • デメリット: 最悪時のレイテンシが極めて悪化します。絞り込み条件が厳しい場合、わずか10件の有効な結果を得るために、500〜1,000件もの予測不能な数の近傍ポイントをスキャンしなければなりません。

3. Qdrantシングルステージエンジン

  • メリット: 数千万件規模のコレクションでも10ミリ秒未満の検索レイテンシを実現します。ディスク上のメモリマップトファイル(mmap)をサポートし、ネイティブな8ビットスカラー量子化により、再現率の低下を1%未満に抑えつつRAM消費量を最大75%削減できます。
  • デメリット: プライマリデータストアとは別に、監視、バックアップ、スケール管理が必要なステートフルサービスが1つ増えることになります。

推奨スタック: Qdrant + FastEmbed

PythonでQdrantとFastEmbedを組み合わせることで、無駄がなく本番運用に耐えうる検索パイプラインを構築できます。50万件未満のドキュメントコーパスであれば、BAAI/bge-small-en-v1.5やall-MiniLM-L6-v2のような384次元のモデルが、運用コストを抑えながら確かなセマンティック精度を提供します。AWSのc6i.xlargeインスタンス(4 vCPU、8 GB RAM)を用いたベンチマークでは、毎秒120件の同時リクエスト環境下でも、この構成でp95クエリレイテンシを8ミリ秒未満に維持できました。

システムは以下の3つのコンポーネントで構成されます:

  1. Qdrantデーモン: DockerまたはKubernetes内で動作し、ベクトルグラフ、転置ペイロードインデックス、およびメモリマップされたセグメントファイルをホストします。
  2. 推論レイヤー: PythonがFastEmbedを通じて軽量なトークン化とONNX Runtimeによる埋め込み生成を処理し、CPUオーバーヘッドを最小限に抑えます。
  3. チューニングされたHNSWインデックス: 最適化されたグラフパラメータにより、インデックス構築速度とリアルタイムな高速クエリ探索のバランスをとります。

実装手順の解説

デーモンの設定、コレクションスキーマの初期化、主要フィールドのインデックス作成、そして複雑なフィルターを用いたクエリ実行の手順を順を追って見ていきましょう。

ステップ1: DockerでQdrantを起動

永続ボリュームストレージを指定してQdrantコンテナを起動します。HTTP通信用にポート6333、高スループットなgRPC通信用にポート6334を公開します:

docker run -d --name qdrant-server \
  -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage:z \
  qdrant/qdrant:v1.12.1

ステップ2: Pythonライブラリのインストール

公式クライアントとFastEmbedをインストールします。FastEmbedにはONNX Runtimeが同梱されているため、大掛かりなPyTorchへの依存を回避できます:

pip install qdrant-client fastembed

ステップ3: コレクションスキーマとHNSW設定の定義

密なFP32ベクトルは急速にメモリを消費します。384次元のベクトル100万件を生の32ビット浮動小数点数で保持すると、座標データだけで約1.5 GBのRAMが必要になり、さらにグラフのオーバーヘッドも加わります。INT8スカラー量子化を有効にすることで、Qdrantは各4バイトの浮動小数点を1バイトの整数に圧縮し、メモリフットプリントを約400 MBまで削減します。ベクトルにon_disk=Trueを設定すれば、ホットなベクトルと量子化された表現のみをRAM上に保持させることができます。

from qdrant_client import QdrantClient
from qdrant_client.http import models

client = QdrantClient(host="localhost", port=6333)

COLLECTION_NAME = "tech_kb_articles"
VECTOR_SIZE = 384  # BAAI/bge-small-en-v1.5の出力次元数に一致

client.recreate_collection(
    collection_name=COLLECTION_NAME,
    vectors_config=models.VectorParams(
        size=VECTOR_SIZE,
        distance=models.Distance.COSINE,
        on_disk=True  # 生のFP32ベクトルをディスクに保持し、mmap経由でページング
    ),
    hnsw_config=models.HnswConfigDiff(
        m=16,               # ノードあたり16リンク。グラフ探索を高速かつ軽量に維持
        ef_construct=100,   # 挿入時に各ポイントに対して100個の候補近傍ノードを評価
        on_disk=False       # 高速な遷移のためにHNSWナビゲーショングラフをRAM上に保持
    ),
    quantization_config=models.ScalarQuantization(
        scalar=models.ScalarQuantizationConfig(
            type=models.ScalarType.INT8,
            quantile=0.99,  # 極端な外れ値を除外してダイナミックレンジを維持
            always_ram=True # コンパクトな1バイト表現をメモリ上に保持
        )
    )
)

print(f"コレクション '{COLLECTION_NAME}' の設定が完了しました。")

ステップ4: ペイロードメタデータのインデックス作成

ペイロードインデックスがない状態でフィルタリングを行うと、Qdrantは検索時にディスク上の生のメタデータJSONブロブを走査せざるを得なくなります。明示的なフィールドインデックスを作成することで転置リストや数値ツリーが構築され、グラフ探索中に即座にフィルター条件をチェックできるようになります:

# カテゴリの完全一致検索用のキーワードインデックス
client.create_payload_index(
    collection_name=COLLECTION_NAME,
    field_name="category",
    field_schema=models.PayloadSchemaType.KEYWORD,
)

# 高速なB-Tree範囲評価を可能にする数値インデックス
client.create_payload_index(
    collection_name=COLLECTION_NAME,
    field_name="view_count",
    field_schema=models.PayloadSchemaType.INTEGER,
)

print("ペイロードインデックスが登録されました。")

ステップ5: 埋め込みの生成とバッチ登録

FastEmbedを使ってドキュメントの埋め込みを生成し、メタデータペイロードと一緒にQdrantへアップロードします:

from fastembed import TextEmbedding
from uuid import uuid4

embedding_model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5")

documents = [
    {
        "text": "Dockerブリッジネットワークにおける接続タイムアウトとDNSエラーのトラブルシューティング。",
        "category": "devops",
        "status": "published",
        "view_count": 1250
    },
    {
        "text": "Nginxにおけるレート制限、トークンバケット、プロキシバッファリングの設定。",
        "category": "devops",
        "status": "published",
        "view_count": 3400
    },
    {
        "text": "Python AsyncIOとSQLAlchemy 2.0を用いたクリーンな非同期データベースクエリの記述。",
        "category": "python",
        "status": "published",
        "view_count": 5100
    },
    {
        "text": "レガシーなPostgreSQLレプリケーションのフェイルオーバー手順を詳述した内部下書き。",
        "category": "database",
        "status": "draft",
        "view_count": 40
    }
]

texts = [doc["text"] for doc in documents]
embeddings = list(embedding_model.embed(texts))

points = [
    models.PointStruct(
        id=str(uuid4()),
        vector=emb.tolist(),
        payload=doc
    )
    for doc, emb in zip(documents, embeddings)
]

client.upsert(
    collection_name=COLLECTION_NAME,
    points=points
)

print(f"'{COLLECTION_NAME}' に {len(points)} 件のレコードを登録しました。")

ステップ6: シングルステージのペイロードフィルターを用いたクエリ実行

コレクションに対してフィルター付き検索を実行します。QdrantはHNSWグラフを探索しながら、フィルター条件を動的に評価します:

query_text = "コンテナ内のパケットドロップとネットワークタイムアウトの解決方法"
query_vector = list(embedding_model.embed([query_text]))[0].tolist()

search_filter = models.Filter(
    must=[
        models.FieldCondition(
            key="category",
            match=models.MatchValue(value="devops")
        ),
        models.FieldCondition(
            key="status",
            match=models.MatchValue(value="published")
        )
    ],
    should=[
        models.FieldCondition(
            key="view_count",
            range=models.Range(gte=1000)
        )
    ]
)

results = client.search(
    collection_name=COLLECTION_NAME,
    query_vector=query_vector,
    query_filter=search_filter,
    limit=3,
    search_params=models.SearchParams(
        hnsw_ef=64,       # 探索深度。値を大きくするとわずかなレイテンシ増と引き換えに再現率が向上
        quantization=models.QuantizationSearchParams(
            rescore=True   # ディスク上の生のFP32ベクトルに対して上位候補を再ランク付け
        )
    )
)

for hit in results:
    print(f"スコア: {hit.score:.4f} | カテゴリ: {hit.payload['category']} | テキスト: {hit.payload['text']}")

rescore=True の設定は、両者の利点を巧みに両立させます。QdrantはRAM上の軽量な8ビット量子化ベクトルを用いてグラフを探索し、1ミリ秒未満で候補アイテムを特定します。上位候補に絞り込んだ段階で、ディスクから元のFP32ベクトルを読み出して最終リストを再スコアリングします。これにより、非圧縮ベクトルをメモリ上に保持する膨大なRAMコストを支払うことなく、完全な浮動小数点精度を維持できます。

Share: