こんにちは~ 浮田です。
S3 Vectors と OpenSearch を比べるシリーズ、違い編、コスト編に続いて、今回は精度編です。
■ TL;DR
違い編では、Recall@10 を「0.975 対 0.981 で差なし」と数行で済ませました。ただ、測っている途中でひとつ引っかかったことがあります。
以前、自分のブログを次世代版の OpenSearch Serverless で意味検索できるようにした記事で、私はこう書いています。「次世代版では engine を省略してもそのまま動きました。マッピングがシンプルになりました」。
ところが今回、同じ感覚で "engine": "faiss" を付けてインデックスを作ろうとしたら、弾かれました。省略が正解だったのではなく、指定できなかったんです。
じゃあ、自分のコレクションの中では何が選ばれていたのか。それで精度は大丈夫なのか。比較記事を読みながらずっと気になっていた「S3 Vectors は安いぶん精度が落ちるのでは」という疑問と一緒に、測ってみました。
なお、HNSW(あとで説明します)のパラメータのことを、この記事では「つまみ」と呼びます。
近似最近傍探索(ANN)は、名前のとおり近似です。
速さのために正確さを一部捨てていて、どれだけ捨てているかは測らないと分かりません。ブログ検索編までの私は、測らずに使っていました。
測る指標は Recall@k です。ANN が返した k 件のうち、本当に近い上位 k 件が何件含まれていたか。「本当に近い」は総当たりで求めます。全件と距離を計算するので遅いですが、必ず正解が出ます。
ANN は全件を見ずに近そうなところだけを見るので、本当は上位に入るはずのものを見に行かないことがあります。その見逃さなかった割合がリコールです。
OpenSearch の k-NN は、アルゴリズム(method)と実装ライブラリ(engine)の組み合わせで決まります。Lucene で使えるのは HNSW だけ、Faiss なら HNSW と IVF が使えます。総当たりの flat も指定できます。
Faiss にはもうひとつ、ベクトル圧縮があります。1,024 次元の float32 をそのまま持たず、精度を落とした形で持っておく仕組みです。あとで出てくる「32 倍圧縮」がこれで、この記事の後半の主役になります。
各アルゴリズムのイメージ図
HNSW には、精度と速度のバランスを調整するつまみが 3 つあります。
ビルド時に決めるのが m(1 ノードから張るエッジの本数)と ef_construction(エッジの相手を探すとき、候補をいくつ抱えて探索するか)で、検索時に決めるのが ef_search(検索のとき、候補をいくつ抱えて探索するか)です。
ef_search を上げるとリコールが上がり、そのぶん遅くなります。
ここで「候補をいくつ抱えるか」をビーム幅と呼びます。幅 1 は「一番よさそうな隣へ進むだけ」の貪欲探索で、候補リストを持たないので脇道に入ったら戻れません。上の層がやっているのはこれです。
複数の候補を抱えるのは最下層に着いてからで、そのときの個数が ef_search です。検索時のつまみが実質ひとつなのはそのためで、これは調べていて初めて知りました。
冒頭の話に戻ります。ブログ検索編と同じ感覚で engine を付けたら、こう返ってきました。
illegal_argument_exception: Field parameter 'engine' is not supported
▲ 公式ブログのチュートリアルに載っている書き方なのに弾かれる調べてみると、このチュートリアルは【Classic 向け/次世代版の GA 前】に書かれたものでした。次世代版のドキュメントには「engine と mode はマッピングに不要で、システムが最適な構成を内部で決める」とあります。
推測しても仕方ないので、マッピングを 13 種類作って投げました。分かったのは次の 3 つです。
投げたマッピングのうち、主なものがこちらです。
|
試したマッピング
|
結果
|
保存された定義 / エラー
|
|
method を丸ごと省略(既定)
|
◎通った
|
mode: on_disk, compression_level: 32x
|
|
engine: faiss を明示(公式ブログのClassic 向けチュートリアルの書き方)
|
弾かれた
|
Field parameter 'engine' is not supported
|
|
method: {name: hnsw, space_type: cosinesimil}
|
◎通った
|
engine: faiss が補われ、on_disk, 32x
|
|
上に parameters: {m: 32, ef_construction: 256}
|
◎通った
|
m と ef_construction がそのまま保存
|
|
method: {name: ivf}
|
弾かれた
|
"method" requires training
|
|
space_type: cosinesimil をフィールド直下に
|
◎通った
|
on_disk, 32x, cosinesimil
|
|
index 設定 knn.algo_param.ef_search: 256
|
◎通った
|
設定に ef_search: 256 が保存
|
|
mode: in_memory を明示
|
弾かれた
|
Field parameter 'mode' is not supported
|
|
compression_level: 1x を明示
|
◎通った
|
compression_level: 1x(mode は付かない)
|
|
mode: on_disk + compression_level: 4x
|
弾かれた
|
Field parameter 'mode' is not supported
|
|
index 設定 knn.remote_index_build.enabled: false
|
◎通った
|
GPU 加速がオフになる
|
|
text に kuromoji アナライザ
|
◎通った
|
-
|
こうなると、比べる相手は S3 Vectors と OpenSearch の 2 つではなく、どこまで自分で決めるかで分かれる 3 層になります。
|
|
アルゴリズム |
圧縮 |
ビルド時のつまみ |
検索時の ef_search |
運用の手間 |
|
OpenSearch マネージドドメイン |
HNSW / IVF / Flat、engine も選べる |
選べる |
m, ef_construction |
指定できる |
大きい |
|
OpenSearch Serverless 次世代版 |
HNSW に固定(engine 不可) |
既定 32x、1x は明示できる |
書ける |
書ける(効くかは後述) |
小さい |
|
S3 Vectors |
非公開 |
非公開 |
なし |
なし |
ほぼゼロ |
自由度と手間が反比例していて、次世代版はその真ん中です。
ブログ検索編で「マッピングがシンプルになった」と喜んでおいて、今回「つまみがない」と文句を言いかけていたのですが、同じ事実に二度違う感想を言っているだけでした。
つまみの有無を決めているのは、サービス名ではなくマネージドの度合いです。
コーパスと埋め込みは違い編と同じです。
※コーパス: 検索対象として用意したデータ一式。
2,798 チャンク(自分の記事 144、e-Gov の法令条文 2,154、日本語 Wikipedia の導入部 500)を Titan Text Embeddings V2 で 1,024 次元に埋め込み、東京リージョンで測っています。精度を測るために足したのは次の 3 点です。
正解を作るのに Flat 用のドメインは立てていません。ベクトルは手元にあるので、numpy の行列積で総当たりの上位 10 件を出しています。
母集団を 3,000 件近くまで増やしたのには理由があって、最初はブログ検索編の数十チャンクで測ろうとして失敗しています。
先に結果です。
表の p50 は応答時間の中央値、p95 は遅いほうから 5% のラインです。
|
|
Recall@10 |
p50 |
p95 |
|
S3 Vectors |
0.975 |
87 ms |
108 ms |
|
次世代版の既定(on_disk 32x) |
0.981 |
77 ms |
112 ms |
|
次世代版 compression_level: 1x |
0.968 |
76 ms |
111 ms |
正直、S3 Vectors のほうがかなり落ちると思っていました。公式の表現が「多くのデータセットで平均 90% 以上」と控えめだったからです。
差は 0.006 で、100 クエリ × 10 件のうち 6 件ぶんです。差がないと言ってよい数字です。
意外だったのは次世代版の 2 本の順序です。32 倍圧縮のほうが、圧縮なしより高い。
理屈はたぶんこうではないかと考えました。
OpenSearch のディスクモードは、圧縮したベクトルで候補を多めに拾ってから、元のベクトルで並べ直します。候補を多く拾うぶん取りこぼしが減ります。
圧縮なしのほうは HNSW をそのまま辿るので、既定の ef_search で拾える範囲がそのまま出ます。3,000 件規模だと、圧縮の損より候補の広さの得が勝ったようです。規模が大きくなれば逆転するかもしれません。
ここだけ見ると「S3 Vectors で十分じゃん」になります。フィルタなしのセマンティック検索に限れば、それはそのとおりだと思います。
ただし本番の RAG では、テナントや言語、公開範囲、更新日など、何かしらのフィルタが必ず付きます。フィルタなしの数字だけでは判断できません。
選択率を 4 段階に振って測り直しました。
|
選択率 |
S3 Vectors |
次世代版の既定 |
次世代版 1x |
|
フィルタなし |
0.975 |
0.981 |
0.968 |
|
25% |
0.985 |
0.984 |
0.983 |
|
5% |
0.992 |
0.994 |
0.994 |
|
1% |
0.991 |
0.990 |
0.990 |
崩れませんでした。それどころか、絞るほど上がっています。
フィルタで崩れる理屈はこうです。
最下層で、グラフを辿る途中にフィルタへ合わないノードばかり通ると、目的地に着く前に候補が尽きます。
これを防ぐために OpenSearch には efficient k-NN filtering があって、探索中にフィルタを適用して k 件返ることを保証します。母集団が小さくなれば総当たりに切り替える挙動もあります。1% なら 28 件しか残らないので、事実上の総当たりです。
S3 Vectors も同じ形で上がっています。中で何をしているかは非公開ですが、少なくとも「フィルタで崩れる」タイプの実装ではなさそうです。
ベンダーが出すリコール値はフィルタなしの数字なので、本番のフィルタ付きで崩れないかは警戒していましたが、今回のデータではどちらも耐えました。
書けたつまみが効くかどうかです。
クエリに method_parameters を付けて、16 から 512 まで振りました。
"query": { "knn": { "embedding": {
"vector": [...], "k": 10,
"method_parameters": { "ef_search": 256 }
}}}
▲ 検索時に ef_search を渡す書き方。エラーにはならない
|
ef_search |
次世代版の既定 |
次世代版 1x |
|
16 |
0.975 |
0.955 |
|
32 |
0.975 |
0.958 |
|
64 |
0.973 |
0.958 |
|
128 |
0.973 |
0.958 |
|
256 |
0.973 |
0.955 |
|
512 |
0.973 |
0.958 |
エラーにはなりませんが、数字も動きません。1x の列は 0.955〜0.958 の間で揺れているだけで、16 と 512 に差はありません。
通常の HNSW なら ef_search: 16 で k=10 を返せば、リコールは目に見えて落ちるはずです。
理由の候補は 2 つあります。受け付けているが無視されているのか、この規模ではセグメント(インデックスを分割して持つ単位)が小さすぎて、探索がほぼ全件に届いているのか。
後者はけっこう有力だと思っています。
もうひとつ気になっているのが、実測①との差です。method_parameters を付けない実測①では既定 0.981、1x 0.968 だったのに、付けると 16〜512 のどれでも少し低く出ています(既定 0.973〜0.975、1x 0.955〜0.958)。値を振っても動かないのに、付けるだけで一段下がるようにも見えます。測定ごとの揺れなのか、付けたことで挙動が変わっているのかは、まだ切り分けていません。
分かるのは「今回の規模では、書いても値によって数字が変わらなかった」ことだけです。
効かないと言い切るには、母集団を増やすか、マネージドドメインで同じことをやって比べる必要があります。そこは次の機会に。
Recall の数字では差が出なかったので、次は検索の種類そのものが違うところを見ます。
違い編で、型番やエラーコードを指名するクエリ 8 本を、S3 Vectors、次世代版の意味検索、次世代版のキーワード検索の 3 系統に投げて、上位 5 件に狙ったチャンクが入るかを見ました。結果は意味検索が 8 本中 7 本、キーワード検索が 8 本とも。数字だけ見ると、意味検索もかなり健闘しています。
ここで見たいのは、当たった 7 本の中身です。
CWE-79 は XSS を指す脆弱性の分類番号で、型番やエラーコードと同じく、文字列そのものを指名して引きたい語として使用します。
意味検索で引いたときの上位 5 件は、自分の記事 3 本のほかに「ワット・チェーディールワン」(タイの寺院)と「香南かがみインターチェンジ」でした。意味検索にとって CWE-79 は「英字とハイフンと数字の並び」でしかなく、文字列として一致しているかは見ていません。当たったのは、コーパスの中で CWE-79 に触れている記事が、たまたま「完全一致検索が苦手」という文脈で書かれていたからです。
外した 1 本は「後から変えられない設定」です。記事には「次元数と距離関数は作成後に変更不可」と書いてあるのに、意味検索は S3 Vectors、次世代版とも上位 5 件に入りませんでした。キーワード検索なら「次元」で 1 発です。
ブログ検索編では、「チャットの返事が二重に表示されるバグ」で検索したら、狙っていたセクションが 1 位に来ず、表示まわりの話題が束になって出てきました。あのとき「関連トピックをまとめて拾ってくる挙動が体感できた」と書きましたが、あれは長所であると同時に短所で、指名して 1 件を引きたいときには邪魔になります。タイの寺院が出てくるのも同じ現象です。
ベクトル検索は、意味を 1 つの点に潰しています。「だいたいこの辺」を 50 件持ってくるのは得意ですが、「この 50 件を正しく並べる」と「CWE-79 を含むものだけ出す」は苦手です。潰した情報は戻せません。
OpenSearch にキーワード検索(BM25)と組み合わせるハイブリッド検索、kuromoji での形態素解析(次世代版でも使えました)、検索パイプラインでのリランクが用意されているのは、これを補うためです。S3 Vectors にはどれもありません。
私は最初、精度 = リコールだと思って測り始めました。でもリコールは「正しい候補を拾えたか」の指標で、「正しく並べたか」は見ていません。RAG の体験を決めるのは後者です。
今回の Recall@10 の 0.975 と 0.981 の差より、上位 5 件にタイの寺院が入るかどうかのほうが、使う側には大きく効きます。ベンチマークの数字が並んでも判断できなかった理由は、たぶんここでした。
<補足:高次元のデータについて> プレビュー期の re:Post に、960 次元の GIST データセットでリコールがほぼ 0 になった、という報告がありました。96 次元の DEEP では問題なかったそうです。 今回は 1,024 次元の正規化済みベクトル(長さを 1 に揃えたベクトル。Titan Text Embeddings V2 は既定で正規化して返します)で 0.975 なので、GA で改善しているか、正規化の有無が効いているかのどちらかだと思います。高次元で正規化していないデータを扱うなら、自分で測ったほうがよさそうです。
S3 Vectors については、S3 の一部である以上、パラメータチューニングを持ち込まない設計なのだと思います。S3 にストレージクラスはあってもインデックス設計はなく、ef_search を触れるようにした時点でストレージではなく検索エンジンになってしまいます。
次世代版は「エージェント向けに作り直した」とアピールされていて、インスタントにスケールし、10 分で 0 に落ち、約 10 秒で復帰します。
既定が on_disk の 32 倍圧縮なのは、この動きと辻褄が合います。メモリに全部載せる前提を捨てれば、復帰は速く、OCU は小さくて済みます。mode も engine も選ばせないのは、その前提を崩されないためではないかと思います。
ブログ検索編で喜んだ「10 秒で復帰」と、今回困った「つまみがない」は、同じ設計判断から来ているのだと思います。
つまみを全部触りたければマネージドドメインです。マネージドドメインなら engine に s3vector を指定して、機能を持ったままストレージだけ S3 Vectors に置くこともできます(OR1 系のインスタンスと 2.19 以上、保存時暗号化が必要で、インデックス作成時にしか指定できません)。
"embedding": {
"type": "knn_vector", "dimension": 1024,
"method": { "engine": "s3vector" }
}
▲ マネージドドメインでストレージ層を S3 Vectors にする指定
ドキュメント上は、この 1 行でストレージ層が入れ替わることになっています。まだ試していないので、次回の宿題です。
精度編で踏んだのは次の 2 つです。
|
症状 |
最初の誤解 |
本当の原因 |
|
ブログ検索編の数十チャンクで Recall を測ったら、どれも 1.000 |
測定スクリプトのバグ |
母集団が小さすぎて近似する余地がない。3,000 件近くまで増やしてから差が見えるようになった |
|
"engine": "faiss" を付けたら Field parameter 'engine' is not supported |
公式ブログのチュートリアルに載っている書き方なので、自分の環境を疑った |
次世代版では engine 自体が指定できない。何も書かなければ faiss が補われる |
違い編では「S3 Vectors か OpenSearch か」で書きましたが、精度とつまみの視点を足すと、選ぶのは 3 層のどれかになります。
|
やりたいこと |
選ぶ層 |
注意点 |
|
セマンティック検索だけでよく、フィルタも緩い |
S3 Vectors |
リコールは次世代版と変わらなかった。つまみは一切ない |
|
型番やエラーコードの完全一致、日本語検索の作り込みが要る。運用は任せたい |
OpenSearch Serverless 次世代版 |
キーワード検索・ハイブリッド検索・kuromoji は使える。engine と mode は選べず、既定は on_disk 32x |
|
HNSW のつまみで精度をチューニングしたい |
OpenSearch マネージドドメイン |
運用の手間は大きい。ストレージ費用を落としたいなら engine: s3vector で併用 |
どれにするか迷うなら、自分のデータで Recall@k を測るのがいちばん確実です。
ベンダーのベンチマークは合成データ、フィルタなし、チューニング済みの数字なので、フィルタの選択率を変えて自分で測った数字とは別物です。
測る前は「S3 Vectors は安いぶんリコールが落ちるはず」と思っていました。
結果は、フィルタなしで 0.975 対 0.981、フィルタを効かせても両方 0.99 前後。差はありませんでした。
検索の種類が違うところです。CWE-79 で引いたらタイの寺院が返ってくるのは、S3 Vectors の欠点ではなくベクトル検索の性質で、それを補う手段があるかどうかが 2 つを分けています。
一番の収穫は、リコールは「拾えたか」しか見ていないと気づいたことでした。 RAG の体験を決めるのは並び順のほうで、そこは Recall@k には映りません。
もうひとつ大きかったのは、ブログ検索編で自分が作ったコレクションの中身が分かったことです。engine は指定できず、既定はディスク上の 32 倍圧縮。「マッピングがシンプルになった」と喜んで書いたのは、選べなくなったということでした。つまみを手放したのではなく、次世代版を選んだ時点で engine も mode も渡していて、私はそれを知らずに使っていました。
※この記事は 2026 年 9 月時点の挙動に基づきます。数字は私のコーパス(技術記事+法令条文+Wikipedia、2,798 チャンク)での実測値で、データセットによって変わります。