前回の記事では、IBM i (AS/400)のマスターデータに対するセマンティック検索を PostgreSQL + pgvector → Amazon S3 Vectors へ移行しました(その前段の1作目で pgvector による基本構成を構築しています)。サーバーレスでベクトル検索を運用できるようになりましたが、実際の業務では「全件を意味で検索する」のではなく、カテゴリなどで絞り込んだうえでセマンティック検索を行いたいという要望があります。
そこで今回は、S3 Vectors のメタデータフィルタを使って、セマンティック検索にカテゴリによる絞り込み機能を追加します。
本記事のソースコードはGitHubリポジトリで公開しています。
なぜ絞り込みが必要か
セマンティック検索は「意味」で関連性を取りに行く性質上、カテゴリをまたいで意味的に近いものを拾ってきてしまいます。たとえば「ベルト」で検索したときに、工具・自動車部品カテゴリの「タイミングベルト」「ファンベルト」だけでなく、衣料品カテゴリの「紳士用革ベルト」まで意味的な近さで上位に混ざる、というイメージです。
実運用では「工具カテゴリの中で意味検索したい」「ある部門の取扱品の中で意味検索したい」といった、先に絞り込んでから意味検索する ユースケースが多くあります。S3 Vectors のメタデータフィルタはまさにこの絞り込みを実現する機能です。
なお、S3 Vectors のメタデータフィルタは完全一致や範囲指定はできても、文字列の部分一致(LIKE)や曖昧検索はサポートしていません。「カテゴリ=工具」のような 構造化された属性での絞り込み専用 と捉えるのが正確です。
システム構成
ポイントは次の2点です。
- 同期時に
PutVectorsでベクトルにcategoryメタデータを付与 しておく - 検索時にUIで選択されたカテゴリを
QueryVectorsのFilterパラメータに渡す
S3 Vectors のメタデータとは
S3 Vectors はベクトル1件ごとに、JSONライクな構造のメタデータを付与できます。PutVectors API で Metadata フィールドにマップを渡すだけです。
types.PutInputVector{
Key: aws.String("SH00123"),
Data: &types.VectorDataMemberFloat32{Value: vector},
Metadata: document.NewLazyDocument(map[string]any{
"category": "T001", // 商品マスタのカテゴリコード (SHSHCG)
}),
}
メタデータは、デフォルトで全キーがフィルタ可能です。インデックス作成時に MetadataConfiguration.NonFilterableMetadataKeys を指定すれば、特定キーを「フィルタ不可・保存のみ」にできます(フィルタ用インデックスを張らないことでコストやストレージを節約)。
Note: メタデータの構造(フィルタ可能/不可)はインデックス作成時に決まる項目なので、本番運用では「あとから増やすメタデータ」も見越して、最初に
NonFilterableMetadataKeysを設計しておくと安全です。今回はカテゴリ1キーだけのシンプル構成なので、デフォルト(全キーフィルタ可能)でいきます。
実装:ベクトル保存時にメタデータを付与する
s3vectors_client.go の UpsertProductVectors を拡張して、メタデータを受け取れるようにします。
// UpsertProductVectors は複数の商品コードとベクトルを一括保存する(500件ずつバッチ処理)
// metadatas は各ベクトルに対応するメタデータ。nilまたは長さ0の場合はメタデータなしで保存する。
func (v *VectorStore) UpsertProductVectors(
ctx context.Context,
codes []string,
vectors [][]float32,
metadatas []map[string]any,
) error {
// (バリデーション省略)
batchSize := 500
for i := 0; i < len(codes); i += batchSize {
end := min(i+batchSize, len(codes))
putInputs := make([]types.PutInputVector, end-i)
for j := i; j < end; j++ {
input := types.PutInputVector{
Key: aws.String(codes[j]),
Data: &types.VectorDataMemberFloat32{Value: vectors[j]},
}
if metadatas != nil && len(metadatas[j]) > 0 {
input.Metadata = document.NewLazyDocument(metadatas[j])
}
putInputs[j-i] = input
}
_, err := v.client.PutVectors(ctx, &s3vectors.PutVectorsInput{
VectorBucketName: aws.String(v.bucketName),
IndexName: aws.String(v.indexName),
Vectors: putInputs,
})
if err != nil {
return fmt.Errorf("failed to put vectors (batch %d-%d): %w", i, end, err)
}
}
return nil
}
document.NewLazyDocument は AWS SDK の汎用ドキュメント型を作るヘルパーで、map[string]any をそのまま渡せます。
同期処理(sync.go)側では、商品データから Category を取り出してメタデータマップに詰めます。
metadatas := make([]map[string]any, len(batch))
for j, p := range batch {
texts[j] = p.Name + " " + p.Category + " " + p.Description
codes[j] = p.Code
meta := map[string]any{}
if p.Category != "" {
meta["category"] = p.Category
}
metadatas[j] = meta
}
embeddings, _ := s.cohereClient.GenerateEmbeddings(ctx, texts, "search_document")
s.db.UpsertProductVectors(ctx, codes, embeddings, metadatas)
実装:検索時にメタデータでフィルタする
クエリ側は QueryVectorsInput.Filter にメタデータ条件を渡します。条件は MongoDB ライクな構文で、$eq, $in, $and, $or, $gt などが使えます。今回はカテゴリの完全一致なので、シンプルに {"category": "T001"} (工具のカテゴリコード) です。
// SemanticSearch はベクトル類似度検索を実行する
// filter にメタデータ絞り込み条件を渡せる(空またはnilでフィルタなし)
func (v *VectorStore) SemanticSearch(
ctx context.Context,
queryVector []float32,
filter map[string]any,
limit int,
) ([]CodeWithScore, error) {
input := &s3vectors.QueryVectorsInput{
VectorBucketName: aws.String(v.bucketName),
IndexName: aws.String(v.indexName),
QueryVector: &types.VectorDataMemberFloat32{Value: queryVector},
TopK: aws.Int32(int32(limit)),
ReturnDistance: true,
}
if len(filter) > 0 {
input.Filter = document.NewLazyDocument(filter)
}
output, err := v.client.QueryVectors(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to query vectors: %w", err)
}
// (結果整形は省略)
}
ハンドラ側では、リクエストの category パラメータを filter に変換して渡します。
var filter map[string]any
if category != "" {
filter = map[string]any{"category": category}
}
codeResults, err := h.db.SemanticSearch(ctx, queryVector, filter, limit)
これで、セマンティック検索の結果から 指定カテゴリ以外の商品が一切返ってこなくなります。
フロントエンド:カテゴリプルダウンを追加
検索バーの隣にカテゴリ選択プルダウンを置き、アプリ起動時にAS/400からカテゴリ一覧を取得して候補を埋めます。選択されたカテゴリは検索リクエストの category パラメータとしてサーバーに渡され、上で実装した Filter に反映されます。
S3 Vectors の挙動メモ:メタデータフィルタは in-tandem 方式
S3 Vectors はメタデータフィルタを ベクトル検索と同時に評価する(in-tandem 方式) という仕組みを採用しています。
S3 Vectors performs vector search and filter evaluation in tandem. (...) In contrast to applying the metadata filter after the vector search, this filtering approach is more likely to find matching results.
― AWS S3 User Guide: Metadata filtering
Pinecone など一部サービスで知られる「ANN で TopK を取ってから後段フィルタで絞った結果、TopK 未満しか返らない」式のハマりは、S3 Vectors では構造的に発生しにくい設計になっています。フィルタ条件にマッチするベクトルが十分に存在する限り、指定した TopK は満たされる挙動です。
ビフォーアフター
同じクエリでも、メタデータフィルタの導入前後で結果がどう変わるかを比較します。
クエリ「トルク」のセマンティック検索結果:
- フィルタなし … トルクと意味的に近い別カテゴリ商品(例: 工具とは別カテゴリにある「トルクテスター」)が混在
- カテゴリ「工具」で絞り込み … 工具カテゴリ内で関連性の高い順に整列。業務上見たい結果だけが並ぶ
意味検索の「ふわっと拾える」良さを保ちつつ、業務上のスコープから外れたものをきちんと除外できるようになりました。
まとめ
今回の改善で、IBM i セマンティック検索デモは以下のように進化しました。
| 観点 | これまで | 今回の改善 |
|---|---|---|
| 絞り込み | なし。全商品が候補 | S3 Vectors の Filter で カテゴリ絞り込み |
| メタデータ運用 | 商品コード + ベクトルのみ |
category メタデータも一緒に保存 |
| UI | 検索バーのみ | カテゴリプルダウン追加 |
S3 Vectors のメタデータフィルタは構造化された属性での絞り込み専用ですが、業務システムにおける「先にスコープを切ってから意味検索する」というユースケースに非常によくフィットします。インデックスの再作成は不要(メタデータ無しの既存ベクトルも、同じKeyで再 PutVectors すれば上書きでメタデータが付与される)なので、既存システムへの導入もスムーズです。
参考リンク
- Amazon S3 Vectors - メタデータフィルタ
- AWS SDK for Go v2 - s3vectors パッケージ
- 前回の記事: pgvectorからAmazon S3 Vectorsへ移行してみた
- 1作目: IBM i(AS/400)にあるマスターデータを「意味」で検索する
- 本プロジェクトのGitHubリポジトリ

