AIシステムのPoC構築で直面する現実の課題
前四半期の終盤、私たちのチームは急ぎのタスクを引き受けました。ファッションアイテム20万件のカタログを対象とした、マルチモーダル検索(multimodal search)のPoC(概念実証)構築です。ユーザーがスマートフォンで撮影した写真をアップロードするか、短いテキストを入力することで、デザインやカラーが類似したアイテムを検索できるようにするという要件でした。
バックエンドエンジニアがまず思いつくのは、スタンドアロンのMilvusやQdrantクラスタを立ち上げることでしょう。しかし初日早々、インフラの現実的な壁に直面することになりました。
- 深刻なメモリ消費: 多くのスタンドアロン型Vector Databaseは、10ms未満の低レイテンシを維持するためにHNSWインデックス全体をRAM上に保持します。512次元のCLIPベクトル15万件とメタデータを試験的にインジェストしたところ、コンテナだけで6GB以上のRAMを消費しました。8GBの検証サーバーはあっという間にスワップを使い果たし、クラッシュしてしまったのです。
- 過大なDevOpsオーバーヘッド: チーム内の全エンジニアが数ギガバイトに及ぶDockerイメージを手元にプルし、ポートフォワーディングを設定し、ヘルスチェックを管理する必要がありました。安価なステージング用VPSにデモ環境をデプロイすると、OOM killerによってサービスが停止し続ける事態に陥りました。
- 複雑なメタデータフィルタリング: データにはベクトルのほか、価格、カテゴリ、在庫数といったテーブル形式のメタデータも含まれます。専用エンジン上でセマンティック検索と厳密なフィルタ(フィルター条件)を組み合わせるには、複雑な複合インデックスの設計が求められることが少なくありません。
PoCや社内向けマイクロサービス程度であれば、分散クラスタを組む必要はありません。この段階でチームが本当に必要としていたのは、組み込み型(embedded database)のソリューションでした。
多くのVector Databaseが重量級である理由
アーキテクチャの観点から深掘りすると、従来のVector Databaseシステムには主に2つのボトルネックが存在します。
1. インデックスをすべてRAM上に保持する仕組み(In-memory Indexing)
HNSW(Hierarchical Navigable Small World)アルゴリズムは、ベクトル点間の近傍グラフを構築します。この手法の致命的な弱点は、グラフ全体をメインメモリ上に常駐させなければならない点です。768次元のベクトル(text-embedding-3-smallやBERTモデルなど)の場合、生データを除いたグラフ構造だけで、100万件あたり4GB〜8GBのRAMを消費してしまいます。
2. クライアント・サーバー構成に起因するレイテンシ
クライアント・サーバーモデルでは、アプリケーションとデータベース間の通信にHTTPやgRPCが必須となります。この構成はネットワークホップを発生させ、バイナリデータのシリアライズ/デシリアライズコストも生じさせます。単にオフラインのPythonスクリプトやCLIツールにセマンティック検索を組み込みたいだけの場合、クライアントマシン上でバックグラウンドデーモンを常駐させること自体が大きなハードルとなります。
現在主流のベクトルストレージアプローチ
OOMエラーを回避しつつ予定通りPoCを納品するため、チームでは以下の3つの選択肢を比較検討しました。
1. 完全インメモリ型ライブラリの利用(FAISS、Chroma in-memory)
Metaが開発したFAISSは非常に高速かつ完全無償で利用可能です。インデックスを.binファイルやpickle形式でダンプして保存することもできます。
デメリット: FAISSはSQLライクなメタデータフィルタリングに対応しておらず、ACIDトランザクションの仕組みも備えていないため、個別のレコード更新が困難です。
2. RDBMSの拡張機能の活用(pgvector)
既存のPostgreSQLにpgvector拡張を導入する手法は、多くのバックエンドチームにとって馴染み深い選択肢です。
デメリット: pgvector上のHNSWも専用エンジンと同等のRAMを消費します。また、独立したPostgresインスタンスが依然として必要であり、アプリケーション内に組み込める軽量ライブラリとしては完結しません。
3. LanceDB — Lanceフォーマットを採用した組み込み型Vector Database
LanceDBはSQLiteやDuckDBとまったく同じアプローチを採用しており、アプリケーションプロセス内(in-process)で直接動作します。データはローカルディスクのディレクトリ、またはオブジェクトストレージ(S3やCloudflare R2など)に直接書き込まれます。
解決策:LanceDBによるマルチモーダル検索の実装
LanceDBは、以下の3つの技術的特長によってチームが抱えていたボトルネックを的確に解消してくれました。
- ディスクベースのANNインデックス: Apache Arrowと互換性のあるカラムナフォーマット「Lance」をベースに構築されています。SSD上のランダムリードに最適化されているため、すべてのベクトルをRAMにロードすることなくディスクから直接クエリを実行できます。
- ゼロインフラ(運用負荷ゼロ): Dockerもポート設定も不要です。
pipでインストールするだけですぐに稼働します。 - ネイティブなマルチモーダル対応: テキスト、ベクトル、画像URL、メタデータを同一の統一スキーマ内で一元管理できます。
ステップ1:環境の準備
仮想環境を作成し、必要なパッケージをインストールします。
python3 -m venv venv
source venv/bin/activate
pip install lancedb pyarrow sentence-transformers pillow
ステップ2:ローカルデータベースの初期化
ディレクトリパスを指定するだけで、LanceDBが必要なファイル構造を自動的に生成します。
import lancedb
# ローカルディレクトリに保存されるデータベースへ接続
db = lancedb.connect("./data/lancedb_demo")
print("既存のテーブル一覧:", db.table_names())
ステップ3:埋め込みパイプラインの構築(テキスト&画像)
以下は、CLIPモデルを用いてテキストを512次元のベクトルに変換し、LanceDBへ直接保存する完全なパイプラインです。
import lancedb
from sentence_transformers import SentenceTransformer
# 1. テキストと画像の両方に対応したCLIPモデルをロード
print("CLIP-ViT-B-32モデルをダウンロード中...")
model = SentenceTransformer('clip-ViT-B-32')
# 2. サンプル商品データ
products = [
{"id": 1, "name": "メンズ ランニングシューズ", "category": "shoes", "price": 1200000, "text": "マラソン向け クッション性の高いブラックのスポーツシューズ"},
{"id": 2, "name": "撥水ウィンドブレーカー", "category": "clothing", "price": 650000, "text": "雨天ツーリングにも使える軽量薄手のアウター"},
{"id": 3, "name": "耐衝撃ノートPCリュック", "category": "accessories", "price": 450000, "text": "15.6インチ対応 ビジネス向け撥水グレーPCバッグ"},
{"id": 4, "name": "レディース ホワイトスニーカー", "category": "shoes", "price": 890000, "text": "通勤・通学に最適なフラットソールの白スニーカー"}
]
# 3. テキストから埋め込みベクトルを生成
texts = [p["text"] for p in products]
embeddings = model.encode(texts)
for i, product in enumerate(products):
product["vector"] = embeddings[i].tolist()
# 4. LanceDBへデータを書き込み
db = lancedb.connect("./data/lancedb_demo")
tbl = db.create_table("products", data=products, mode="overwrite")
print(f"{len(tbl)} 件のレコードのインジェストに成功しました。")
ステップ4:ハイブリッドクエリ(ベクトル検索+SQLフィルタ)
LanceDBでは、ベクトル距離の計算と並行して標準的なSQL構文によるデータフィルタリングを実行できます。
user_query = "100万ドン以下のトレーニングシューズ"
# 検索クエリを512次元のベクトルにエンコード
query_vector = model.encode(user_query).tolist()
# 事前フィルタ付きベクトル検索:ベクトル比較の前に価格で絞り込み
results = (tbl.search(query_vector)
.where("price < 1000000", prefilter=True)
.limit(2)
.to_pandas())
print("\n--- 検索結果 ---")
for _, row in results.iterrows():
print(f"商品名: {row['name']} | 価格: {row['price']:,}đ | 距離: {row['_distance']:.4f}")
ステップ5:データ増加に伴うIVF-PQインデックスの作成
テーブルのレコード数が数万件から数百件規模に増加した場合は、RAM消費を抑えつつクエリを高速化できるIVF-PQ(Inverted File with Product Quantization)インデックスを作成します。
# ベクトルカラムにIVF-PQインデックスを作成
# 注意: クラスタを効果的に学習させるには最低でも数千件以上のベクトルが必要です
tbl.create_index(
metric="cosine",
num_partitions=64, # ボロノイ分割のクラスタ数
num_sub_vectors=16 # 元のベクトルを16個のサブベクトルに圧縮(量子化)
)
本番環境への導入で得られた実践的なノウハウ
PoCをステージング環境へ展開した際、チームは以下の3つの重要な教訓を得ました。
- S3を活用したサーバーレス構成の実現: 接続関数には
s3://my-bucket/lancedbのようなURIを直接渡すことができます。全ロジックをAWS Lambdaで実行し、データはS3に保持すれば、トラフィックが発生しない時間帯のインフラコストをほぼゼロに抑えられます。 prefilter=Trueの適切な使い分け: フィルタ条件によってデータの80%以上が除外される場合、事前フィルタ(prefilter)を有効にすることで不要なベクトル計算を大幅に削減できます。逆にフィルタ条件が緩い場合は、事後フィルタ(postfilter)のほうが生ベクトルのスキャン速度で上回ることがあります。- 極めてシンプルなデータバックアップ: LanceDBはLanceファイル群とマニフェストファイルで構成されるディレクトリ形式でデータを保持します。ダンプコマンドやテーブルロックを伴うことなく、
rcloneやaws s3 syncを用いた定期バックアップが容易に行えます。

