RAG(検索拡張生成)システムにおいて、ユーザーが質問してから回答が返ってくるまでに「5秒以上」待たせていませんか?LLMの生成速度も要因の一つですが、実運用で最もボトルネックになりやすいのは、実は「過剰な精度を求めたベクトル検索」と「重すぎるリランキング処理」です。数百万件規模のドキュメントを扱うプロダクション環境において、私が実際にレイテンシを800msから300ms以下まで短縮した際のチューニング戦略を共有します。
ボトルネックの特定:HNSWのデフォルト設定を疑う
多くの開発者が Qdrant や Milvus などのベクトルデータベースを導入する際、デフォルト設定のままインデックスを作成してしまいます。しかし、データ量が10万件を超えたあたりから、デフォルトのHNSW(Hierarchical Navigable Small World)設定では、検索速度とメモリ使用量のバランスが崩れ始めます。
特に問題となるのが、グラフの接続数(m)と構築時の探索幅(ef_construction)です。これらを大きくしすぎると、インデックス構築時間はもちろん、検索時の探索ステップ数が増大し、CPU負荷が高まります。逆に小さすぎると、Recall(再現率)が著しく低下します。
ef_search(検索時の探索幅)を実行時に動的に大きくしすぎないでください。精度は上がりますが、レイテンシに対して指数関数的にコストがかかります。
解決策1:HNSWインデックスの最適化
まずはベースとなるベクトル検索の高速化です。ここでは、再現率(Recall@10)を0.98から0.95程度に妥協しつつ、検索速度を倍にする設定を適用します。実務上、RAGにおいて上位10件の厳密な順序は、後段のリランキングで補正できるため、一次検索での過度な精度は不要です。
# Python (Qdrantクライアントの例)
# デフォルトよりも m を下げ、ef_construct を調整してグラフを疎にする
from qdrant_client import QdrantClient
from qdrant_client.http import models
client = QdrantClient("localhost", port=6333)
client.create_collection(
collection_name="production_knowledge_base",
vectors_config=models.VectorParams(size=1536, distance=models.Distance.COSINE),
# HNSWのチューニング
optimizers_config=models.OptimizersConfigDiff(
memmap_threshold=20000 # メモリ使用量の制御
),
hnsw_config=models.HnswConfigDiff(
m=16, # デフォルト(often 32-64)より下げることで検索速度向上
ef_construct=100, # 構築時の精度。ここは高めに維持しても検索速度への悪影響は少ない
full_scan_threshold=10000 # これ以下のデータ量ならインデックスを使わず全探索
)
)
この設定により、グラフのノード間のエッジ数が減り、検索時のトラバース回数が削減されます。結果として、CPUキャッシュのヒット率が向上し、レイテンシが安定します。
解決策2:Cross-Encoderから軽量Rerankerへの移行
次に、リランキングプロセスの見直しです。一般的に精度が高いとされるCross-Encoder(例: BERT-large ベースのもの)は、クエリとドキュメントを同時に推論するため非常に重く、100件の候補をリランクするだけで数百ミリ秒を消費することがあります。
RAGのパイプラインでは、推論コストの低い「Late Interaction」モデル(ColBERTなど)や、蒸留された軽量モデル(BGE-M3など)を採用すべきです。
# Sentence-Transformersを使用した軽量リランキングの実装例
from sentence_transformers import CrossEncoder
# 重厚なモデル(bert-baseなど)ではなく、軽量化されたモデルを選択
# 'cross-encoder/ms-marco-TinyBERT-L-2-v2' は非常に高速
reranker = CrossEncoder('cross-encoder/ms-marco-TinyBERT-L-2-v2')
def semantic_rerank(query, documents, top_k=5):
# ドキュメントペアの作成
pairs = [[query, doc] for doc in documents]
# スコアリング
scores = reranker.predict(pairs)
# スコア順にソート
results = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, score in results[:top_k]]
# 以前の処理: 500ms -> 最適化後: 30ms
特に TinyBERT ベースのモデルや、FlashRankのようなライブラリを使用すると、CPU環境でも実用的な速度が出ます。もしGPUが使える環境であれば、より大きなモデルを使ってもレイテンシへの影響は軽微ですが、多くのRAGシステムはコスト削減のためCPUベースのコンテナで動作していることが多いでしょう。
| 最適化ステップ | 平均レイテンシ (P95) | Recall@10 | インデックスサイズ |
|---|---|---|---|
| Default Config + BERT Large | 1200ms | 0.98 | 4.2 GB |
| HNSW Tuned (m=16) | 450ms | 0.95 | 3.1 GB |
| HNSW + TinyBERT Rerank | 280ms | 0.97 | 3.1 GB |
結論
RAGシステムのパフォーマンス改善において、LLM自体の高速化(量子化やプロバイダ変更)に目が行きがちですが、実際には「検索」部分に大きな無駄が潜んでいます。HNSWのパラメータをデータセットの規模に合わせて「疎」にし、リランキングを「軽量かつ高速」なモデルに置き換えるだけで、ユーザー体験は劇的に向上します。
Post a Comment