Amazon OpenSearch Service が OpenSearch 3.7 をサポートしたので試してみた
クラウド事業統括本部の石川です。Amazon OpenSearch Service で OpenSearch 3.7 が利用可能になりましたので、実際にドメインを立てて新機能を試してみました。
アナウンスで挙げられているのは、ベクトル検索のパフォーマンス、検索関連性(Search Relevance)、Query Insights の 3 領域です。
具体的には、Faiss エンジンおよび Lucene エンジンでの 1 ビットスカラー量子化(1-bit scalar quantization)によるベクトル圧縮、ドキュメントソース(_source)の代わりにドキュメント値(doc values)を使ったベクトル取得の高速化、そして Query Insights への自動クエリレコメンデーション・完了済みクエリキャッシュ・上位クエリの Amazon S3 エクスポートの追加です。
いずれも「既存のインデックスをどうするか」に直結する内容だったため、実際に t3.small.search 1 ノードのドメインを作成して、どこまで再現できるかを確認してみました。
OpenSearch 3.7 の新機能とは
今回のアップデートで追加された機能を簡単に整理します。このうち検証したのは、前者 2 つです。
1 ビットスカラー量子化 は、32 ビット浮動小数点で保持していたベクトルを 1 次元あたり 1 ビットに圧縮する手法です。knn_vector フィールドの method.parameters.encoder に sq エンコーダを指定し、bits に 1 を設定することで有効になります。OpenSearch のドキュメントによると 1 ビット量子化は 3.6 で導入されたもので、Faiss / Lucene 両エンジンで 32 倍の圧縮を実現するとされています。
doc values によるベクトル取得 は 3.7 の目玉機能です。検索リクエストで docvalue_fields にベクトルフィールドを指定すると、_source の展開・逆シリアル化パイプラインを経由せずにベクトルを取り出せます。OpenSearch 3.7 のリリースノートでは、768 次元ベクトルの k=1000 において最大 5.5 倍高速なレイテンシーを実現し、再インデックスは不要と説明されています。
Query Insights の強化 では、上位 N クエリに対する自動レコメンデーション、直近で完了したクエリを参照する finished query cache、上位クエリデータの Amazon S3 エクスポートが追加されました。こちらは今回のスコープから外しています。理由は考察で触れます。
やってみた
今回は AWS マネジメントコンソールと OpenSearch Dashboards の操作を中心に進めます。
前提条件
- 検証リージョン: ap-northeast-1(東京)
- ドメイン構成: t3.small.search × 1 ノード、EBS gp3 10 GB、保管時暗号化・ノード間暗号化あり、パブリックエンドポイント、きめ細かなアクセスコントロール(FGAC)有効・内部ユーザーデータベースのマスターユーザー
- テストデータ: 768 次元ベクトル 5,000 件(HNSW: m=16、ef_construction=256、ef_search=1000)
OpenSearch Dashboards は IAM ユーザーやロールをネイティブにサポートしません。IAM ベースのアクセスポリシーだけの構成では、Dashboards の URL を開いても機能しないサインインページが表示されるだけです。Dev Tools を使うために、きめ細かなアクセスコントロール(FGAC)と内部ユーザーデータベースのマスターユーザーを有効にしています。
REST API へのリクエストは、OpenSearch Dashboards の Dev Tools から実行します。Dev Tools はブラウザのログインセッションで認証されるため、SigV4 の署名処理は不要です。
なお、実際の検証は AWS CLI とスクリプトから実施しており、本記事ではその手順をマネジメントコンソールと OpenSearch Dashboards での操作に読み替えて紹介しています。掲載している所要時間・ストアサイズ・ベンチマークの数値は、すべて CLI/スクリプト実行時の実測値です。
ステップ1: ドメインを作成する
まずは OpenSearch 3.7 のドメインを作成します。AWS マネジメントコンソールで Amazon OpenSearch Service を開き、リージョンを アジアパシフィック (東京) ap-northeast-1 にしたうえで、ナビゲーションペインの ドメイン から ドメインの作成 をクリックします。設定値は以下のとおりです。
| ウィザードの項目 | 設定値 |
|---|---|
| Domain Name | blog-os37 |
| Domain creation method | Standard create |
| Use cases | Vector search |
| Templates | Dev/test |
| Deployment option(s) | Domain without standby |
| Engine options | 3.7 (latest) - recommended |
| Instance family | General purpose |
| Instance type | t3.small.search |
| Number of data nodes | 1 |
| Storage type | Amazon EBS |
| EBS volume type | General Purpose(SSD)- gp3 |
| EBS storage size per node | 10 GiB |
| Network | Public access |
| Fine-grained access control | Enable fine-grained access control/Create master user(username / password) |
| Access policy | Only use fine-grained access control |






Fine-grained access control は、保管時の暗号化・ノード間の暗号化・HTTPS の必須化がすべて有効になっていないと選べません。ウィザードの暗号化設定を先に有効にしておいてください。また、一度有効にすると後から無効化できない点にも注意が必要です。
ステップ2: Dev Tools でバージョンとプラグインを確認する
詳細画面の OpenSearch Dashboards の URL を開き、ウィザードで設定したマスターユーザーでログインします。左のナビゲーションメニューから Dev Tools を選ぶと、REST API を直接実行できるコンソールが開きます。

Dev Tools は画面が左右に分かれていて、左ペインにリクエストを入力し、右ペインにレスポンスが表示されます。リクエストを入力したら、その右上に現れる三角のアイコン(▶)をクリックするか、Ctrl + Enter(macOS は Cmd + Enter)を押すと送信されます。cURL と違ってエンドポイントの URL や認証情報を書く必要はなく、HTTP メソッドとパスだけで実行できます。
まずはルートエンドポイントを叩いてバージョンを確認します。左ペインに以下を入力して実行します。
GET /

OpenSearch 3.7.0、Lucene は 10.4.0 でした。minimum_index_compatibility_version が 2.0.0 になっている点は、後述するアップグレードの制約と対応しています。
導入済みプラグインも確認しておきます。
GET /_cat/plugins?v=true

今回検証する opensearch-knn に加えて、query-insights、そして Search Relevance Workbench の実体である opensearch-search-relevance も、いずれも最初から導入されています。
ステップ3: 1 ビットスカラー量子化のインデックスを作成する
Lucene エンジンと Faiss エンジンそれぞれで、sq エンコーダの bits に 1 を指定したインデックスを作成します。比較用に無圧縮(float32)の Faiss インデックスも用意しました。
インデックスは Dashboards の Index Management からも作成できますが、knn_vector の method.parameters.encoder のような入れ子のパラメータはフォームで指定できません。ここからは Dev Tools にリクエストを貼り付けて実行していきます。
Lucene エンジンのマッピングは以下のとおりです。ベクトルの my_vector に加えて、検索結果の確認用に title と category も定義しておきます。

続いて、engine を faiss に変えた idx_faiss_1bit を作成します。

比較用の無圧縮(float32)インデックス idx_faiss_float は、encoder を指定しないだけです。

3 つともいずれも 200 が返りました。3.6 で導入された 1 ビットスカラー量子化が、3.7 のマネージドドメインでそのまま使えることが確認できました。
なお、すでに同じ名前のインデックスがある状態で PUT を実行すると resource_already_exists_exception が返ります。作り直す場合は先に削除してください。
DELETE /idx_lucene_1bit,idx_faiss_1bit,idx_faiss_float
各インデックスに 768 次元ベクトル 5,000 件を _bulk で投入します。5,000 件の _bulk リクエストは 1 万行を超える NDJSON になるため、Dev Tools に貼り付けるのは現実的ではありません。ここだけはスクリプトから投入しました。
scripts/bulk_load_basic.py
#!/usr/bin/env python3
"""NDJSON を分割して _bulk に Basic 認証で投入する。
FGAC(内部ユーザーデータベース)を有効にしたドメイン向け。
SigV4 版は bulk_load.py を参照。
環境変数:
OS_ENDPOINT ドメインエンドポイント(https://search-xxx.ap-northeast-1.es.amazonaws.com)
OS_USER マスターユーザー名
OS_PASSWORD マスターユーザーのパスワード
使い方:
python3 scripts/bulk_load_basic.py <index> <ndjson> [chunk_docs]
"""
import os
import sys
import time
import requests
ENDPOINT = os.environ["OS_ENDPOINT"].rstrip("/")
AUTH = (os.environ["OS_USER"], os.environ["OS_PASSWORD"])
def send(index: str, payload: bytes):
return requests.post(
f"{ENDPOINT}/{index}/_bulk",
auth=AUTH,
headers={"Content-Type": "application/x-ndjson"},
data=payload,
timeout=180,
)
def main():
index, path = sys.argv[1], sys.argv[2]
# t3.small.search は HTTP ペイロード上限が 10 MiB。768 次元 * 5,000 件なら 250 件程度が安全。
chunk_docs = int(sys.argv[3]) if len(sys.argv) > 3 else 250
lines = open(path, "rb").read().splitlines(keepends=True)
pairs = [lines[i:i + 2] for i in range(0, len(lines), 2)]
total, errors, t0 = 0, 0, time.time()
for i in range(0, len(pairs), chunk_docs):
payload = b"".join(b"".join(p) for p in pairs[i:i + chunk_docs])
r = send(index, payload)
if not r.ok:
print(f" HTTP {r.status_code}: {r.text[:400]}")
return 1
body = r.json()
if body.get("errors"):
errors += sum(1 for it in body["items"] if it.get("index", {}).get("error"))
first = next(it for it in body["items"] if it.get("index", {}).get("error"))
print(f" bulk error sample: {first['index']['error']}")
return 1
total += len(pairs[i:i + chunk_docs])
print(f"{index}: indexed={total} errors={errors} elapsed={time.time() - t0:.1f}s")
return 0
if __name__ == "__main__":
sys.exit(main())
for idx in idx_lucene_1bit idx_faiss_1bit idx_faiss_float; do
python3 scripts/bulk_load_basic.py "$idx" payloads/bulk.ndjson 250
done
idx_lucene_1bit: indexed=5000 errors=0 elapsed=33.1s
idx_faiss_1bit: indexed=5000 errors=0 elapsed=15.6s
idx_faiss_float: indexed=5000 errors=0 elapsed=22.7s
同じデータでも、1 ビット量子化したインデックスは無圧縮の 2 倍以上の投入時間がかかりました。量子化処理がインデックス時のコストとして乗ってくる点は、事前に把握しておきたいところです。
ステップ4: ストアサイズを比較する
セグメント数の影響を排除するため、force merge で単一セグメントに統合してからサイズを比較します。force merge は Dashboards の Index Management から実行できます。
- Dashboards の左メニューから Index Management → Indices を開く
idx_lucene_1bit、idx_faiss_1bit、idx_faiss_floatの 3 つを選択する- Actions → Force merge を選ぶ
- Force merge 画面の Select source indexes or data streams に、対象のインデックスが入っていることを確認する
- 画面下部の Advanced settings を展開し、Max number of segments に
1を入力する - Force merge をクリックする

Max number of segments は初期状態では折りたたまれた Advanced settings の中にあり、画面を開いただけでは見えません。ここに気付かないとセグメント数を指定できないので注意してください。
ストアサイズも同じ Indices 一覧の Total size / Size of primaries 列で確認できます。

同じ操作は Dev Tools からも実行できます。バイト単位の正確な値まで見たい場合はこちらが便利です。
POST /idx_lucene_1bit,idx_faiss_1bit,idx_faiss_float/_forcemerge?max_num_segments=1
GET /_cat/indices?v=true
health status index pri rep docs.count store.size pri.store.size
green open idx_faiss_float 1 0 5000 30.1mb 30.1mb
green open idx_lucene_1bit 1 0 5000 15.5mb 15.5mb
green open idx_faiss_1bit 1 0 5000 16mb 16mb
正確なバイト数は以下のとおりでした。
| インデックス | ストアサイズ(bytes) | float32 比 |
|---|---|---|
| idx_faiss_float(無圧縮) | 31,614,360 | 1.00 倍 |
| idx_faiss_1bit | 16,817,832 | 0.53 倍 |
| idx_lucene_1bit | 16,304,671 | 0.52 倍 |
ディスク使用量はおよそ半分になりました。ただし、これを「32 倍の圧縮」と読むことはできません。ストアサイズには _source に格納された元のベクトル JSON や転置インデックスも含まれており、量子化の効果はそのうち HNSW グラフ部分にしか効かないためです。Lucene のドキュメントにも、量子化は生ベクトルと量子化ベクトルの両方を保存するためディスク使用量はむしろ微増する、と書かれています。
ステップ5: k-NN グラフメモリを比較する
圧縮効果を正しく見るには、ディスクではなく k-NN のグラフメモリを見る必要があります。こちらは対応する UI がないため、Dev Tools から確認します。まず全インデックスをウォームアップしてキャッシュに載せます。

その上でグラフメモリを確認します。

期待した結果とは異なり、キャッシュに載っているのは無圧縮の idx_faiss_float だけで、1 ビット量子化した 2 つのインデックスは現れませんでした。この後 41 回の k-NN 検索を実行してから再計測しても、結果は変わりませんでした。
idx_faiss_float の 15,741 KB(約 15.4 MB)は、OpenSearch が公開している HNSW メモリ概算式 1.1 * (dimension * bits_per_dimension / 8 + 8 * m) bytes/vector に 768 次元・32 ビット・m=16・5,000 件を当てはめた 16.8 MB とほぼ一致します。同じ式に 1 ビットを入れると 1.2 MB になるため、1 ビット量子化側は数値としてもキャッシュ上ほぼ無視できる規模ということになります。
Lucene エンジンについては、そもそも _plugins/_knn/stats のキャッシュ統計が Faiss などのネイティブライブラリ向けの指標であるため、計上されないのは想定どおりです。一方 Faiss の 1 ビット量子化インデックスまで計上されなかった点については、公式ドキュメントに明確な記載を見つけられませんでした(詳細は今後の公式ドキュメントを参照してください)。
ステップ6: 検索結果が一致するかを確認する
量子化によって検索結果がどれだけ変わるのかを、同一のクエリベクトルで確認します。ここも Dev Tools から実行します。
POST /idx_faiss_float/_search
{
"size": 3,
"_source": ["title", "category"],
"query": { "knn": { "my_vector": { "vector": [0.212, 0.461, ...], "k": 3 } } }
}

同様に、idx_faiss_1bit と idx_lucene_1bit も結果を取得します。
POST /idx_faiss_1bit/_search
{
"size": 3,
"_source": ["title", "category"],
"query": { "knn": { "my_vector": { "vector": [0.212, 0.461, ...], "k": 3 } } }
}
POST /idx_lucene_1bit/_search
{
"size": 3,
"_source": ["title", "category"],
"query": { "knn": { "my_vector": { "vector": [0.212, 0.461, ...], "k": 3 } } }
}
全掲すると大きいので、3 つのインデックスの結果は以下のとおりでした。
=== idx_faiss_float ===
took: 338 ms
_id=3954 _score=0.002286 {'title': 'document 3954', 'category': 'music'}
_id=3815 _score=0.002238 {'title': 'document 3815', 'category': 'game'}
_id=4294 _score=0.002235 {'title': 'document 4294', 'category': 'music'}
=== idx_faiss_1bit ===
took: 531 ms
_id=3954 _score=0.002286 {'title': 'document 3954', 'category': 'music'}
_id=3815 _score=0.002238 {'title': 'document 3815', 'category': 'game'}
_id=4294 _score=0.002235 {'title': 'document 4294', 'category': 'music'}
=== idx_lucene_1bit ===
took: 604 ms
_id=3954 _score=0.002286 {'title': 'document 3954', 'category': 'music'}
_id=3815 _score=0.002238 {'title': 'document 3815', 'category': 'game'}
_id=4294 _score=0.002235 {'title': 'document 4294', 'category': 'music'}
上位 3 件の順序もスコアも完全に一致しました。ただしこれは 5,000 件のランダムベクトルという条件での結果であり、実データでの recall を保証するものではありません。なお took は 1 ビット量子化側の方が大きく出ていますが、これはキャッシュ状態が揃っていない初回実行を含む単発計測であり、レイテンシーの比較として扱える値ではありません。
ステップ7: docvalue_fields でベクトルを取得する
ここからが 3.7 の目玉です。まず docvalue_fields でベクトルを取得してみます。

768 次元のベクトルが、768 要素の配列ではなく 4,096 文字の base64 文字列 1 個 として返ってきました。768 次元 × 4 バイト = 3,072 バイトを base64 エンコードすると 4,096 文字になるため、リトルエンディアンの float32 をそのままエンコードした形式ということになります。
比較のため _source でも取得してみます。

こちらは従来どおりの数値配列です。docvalue_fields を使う場合、アプリケーション側で base64 をデコードする処理が必要になる点は押さえておく必要があります。人間が目視で確認したい場合は {"field": "my_vector", "format": "array"} のように形式を明示できます。
ステップ8: docvalue_fields と _source をベンチマークする
ウォームアップ 1 回の後、同一のクエリベクトルで 6 回ずつ実行し、中央値を比較しました。Dev Tools でもレスポンスの took は確認できますが、6 回実行の中央値・クライアント側から見た実測時間・レスポンスサイズまでは計測できないため、この比較だけはスクリプトから実行しています。
bench_retrieval_basic.py
#!/usr/bin/env python3
"""docvalue_fields と _source でのベクトル取得を同一条件で比較するベンチマーク(Basic 認証版)。
OpenSearch 3.7 で追加された docvalue_fields 経由のベクトル取得が、
_source 経由の取得に対してどれだけ速いかを end-to-end 実測する。
FGAC(内部ユーザーデータベース)を有効にしたドメイン向け。
SigV4 版は bench_retrieval.py を参照。
環境変数:
OS_ENDPOINT ドメインエンドポイント(https://search-xxx.ap-northeast-1.es.amazonaws.com)
OS_USER マスターユーザー名
OS_PASSWORD マスターユーザーのパスワード
使い方:
python3 scripts/bench_retrieval_basic.py <index> [k] [iterations]
"""
import json
import os
import statistics
import sys
import time
import requests
ENDPOINT = os.environ["OS_ENDPOINT"].rstrip("/")
AUTH = (os.environ["OS_USER"], os.environ["OS_PASSWORD"])
def search(index: str, body: dict):
url = f"{ENDPOINT}/{index}/_search"
data = json.dumps(body).encode()
t0 = time.perf_counter()
r = requests.post(url, auth=AUTH, headers={"Content-Type": "application/json"},
data=data, timeout=180)
wall = (time.perf_counter() - t0) * 1000
r.raise_for_status()
return r.json(), wall, len(r.content)
def main():
index = sys.argv[1]
k = int(sys.argv[2]) if len(sys.argv) > 2 else 1000
iterations = int(sys.argv[3]) if len(sys.argv) > 3 else 6
qv = json.load(open("payloads/query_vector.json"))
knn = {"knn": {"my_vector": {"vector": qv, "k": k}}}
cases = {
"_source": {"size": k, "query": knn, "_source": ["my_vector"]},
"docvalue_fields": {"size": k, "query": knn, "_source": False,
"docvalue_fields": ["my_vector"]},
}
results = {}
for label, body in cases.items():
search(index, body) # ウォームアップ(計測から除外)
walls, tooks, size = [], [], 0
for _ in range(iterations):
resp, wall, size = search(index, body)
walls.append(wall)
tooks.append(resp["took"])
hits = len(resp["hits"]["hits"])
results[label] = {
"hits": hits,
"took_ms_median": statistics.median(tooks),
"wall_ms_median": round(statistics.median(walls), 1),
"wall_ms_min": round(min(walls), 1),
"response_bytes": size,
}
print(f"{label:16s} hits={hits} took(median)={results[label]['took_ms_median']}ms "
f"wall(median)={results[label]['wall_ms_median']}ms "
f"resp={size / 1024 / 1024:.1f}MB")
s, d = results["_source"], results["docvalue_fields"]
print(f"\n--- k={k}, iterations={iterations}, index={index} ---")
print(f"took ratio (_source / docvalue_fields): "
f"{s['took_ms_median'] / max(d['took_ms_median'], 1):.2f}x")
print(f"wall ratio (_source / docvalue_fields): "
f"{s['wall_ms_median'] / d['wall_ms_median']:.2f}x")
print(f"size ratio (_source / docvalue_fields): "
f"{s['response_bytes'] / d['response_bytes']:.2f}x")
# 元検証のエビデンス(payloads/bench_result.json)は上書きしない
out = f"payloads/bench_result_k{k}.json"
json.dump({"index": index, "k": k, "iterations": iterations, "results": results},
open(out, "w"), indent=2)
print(f"\n結果を {out} に保存しました。")
if __name__ == "__main__":
main()
k=1000 の結果です。
% python3 scripts/bench_retrieval_basic.py idx_faiss_float 100 6
_source hits=1000 took(median)=915.5ms wall(median)=1962.3ms resp=4.8MB
docvalue_fields hits=1000 took(median)=433.0ms wall(median)=1729.2ms resp=4.0MB
took ratio (_source / docvalue_fields): 2.11x
wall ratio (_source / docvalue_fields): 1.13x
size ratio (_source / docvalue_fields): 1.19x
k=100 の結果です。
% python3 scripts/bench_retrieval_basic.py idx_faiss_float 100 6
_source hits=100 took(median)=71.5ms wall(median)=357.1ms resp=0.5MB
docvalue_fields hits=100 took(median)=6.0ms wall(median)=302.7ms resp=0.4MB
took ratio (_source / docvalue_fields): 11.92x
wall ratio (_source / docvalue_fields): 1.18x
size ratio (_source / docvalue_fields): 1.19x
サーバー側の処理時間である took は、k=1000 で 2.11 倍、k=100 では 11.92 倍まで改善しました。一方、クライアントから見たエンドツーエンドの実測時間は 1.13〜1.18 倍にとどまっています。これは東京リージョンのドメインからローカル環境へ 4 MB 前後のレスポンスを転送する時間が支配的になっているためで、ネットワークが近い環境ほど took の改善がそのまま効いてくることになります。
レスポンスサイズの削減は 1.19 倍でした。OpenSearch の解説記事では base64 形式は JSON 配列比で約 60% 小さいとされていますが、今回は小数点以下 3 桁のベクトルを使ったため JSON 配列側が短く、差が縮まったものと考えられます。
考察
今回の検証で分かったことを整理します。
- 1 ビットスカラー量子化の効果は、ストアサイズでは測れません。 今回はディスク使用量が約半分になりましたが、これは
_sourceを含む総量での話です。量子化の本来の効果はメモリにあり、k-NN のグラフメモリで見ると無圧縮側だけが 15,741 KB を消費し、1 ビット量子化側はキャッシュに載りませんでした。検討時は_plugins/_knn/statsを確認するのが確実です。 - 1 ビット量子化はインデックス時のコストが上がります。 同じ 5,000 件の投入で、無圧縮の 15.1 秒に対して 31.8 秒/34.5 秒かかりました。書き込みスループットが要件に入る場合は事前の計測が必要です。
docvalue_fieldsはサーバー側の効果が大きい機能です。tookは k=100 で 11.92 倍、k=1000 で 2.11 倍改善しました。一方でクライアントから見た改善は 1.13〜1.18 倍にとどまり、レスポンス転送時間が支配的でした。ベクトルを大量に返す API の前段にこの機能を入れる場合、ネットワーク経路もあわせて見直す価値があります。docvalue_fieldsの戻り値は base64 文字列です。 既存の_source前提のクライアントコードをそのまま切り替えるとパースに失敗します。デコード処理を追加するか、formatにarrayを指定する必要があります。- コンソールで完結する範囲と、スクリプトが必要な範囲は明確に分かれます。 ドメイン作成はマネジメントコンソール、インデックス作成・k-NN 検索・グラフメモリの確認は Dev Tools、force merge とストアサイズの確認は Index Management で、それぞれ完結します。一方、5,000 件の
_bulk投入と 6 回実行の中央値をとるベンチマークは、コンソールでは実現できません。 - Dashboards を使うなら、きめ細かなアクセスコントロールが事実上の前提です。 IAM ベースのアクセスポリシーだけではブラウザから Dashboards を開けません。有効化するには保管時の暗号化・ノード間の暗号化・HTTPS の必須化がすべて必要で、しかも一度有効にすると無効化できません。あわせて、
_forcemergeや_plugins/_knn/warmupといったクラスター管理 API にはcluster:admin権限が必要になります(マスターユーザーであれば保有しています)。
既存ドメインを 3.7 に上げる場合の制約にも触れておきます。公式ドキュメントによると、OpenSearch 1.3 または 2.x から 3.x へ移行するには先に 2.19 を経由する必要があります。また OpenSearch 1.3 や Elasticsearch 7.10 以前で作成されたインデックスは、ホット・UltraWarm・コールドのいずれに配置されていても再インデックスが必要です。今回作成したドメインの minimum_index_compatibility_version が 2.0.0 であったことは、この制約と一致しています。
今回検証できなかった領域にも触れておきます。
Query Insights は見送りました。_insights/top_queries が保持するのは一定の時間ウィンドウ内のクエリだけで、今回試した範囲では 5 分程度でした。しかもそのウィンドウの大半は、Dashboards が 60 秒間隔で自前のインデックス(.query_execution_request_* や .kibana など)へ投げている背景クエリで埋まってしまいます。自分が投げた k-NN 検索を安定して捉えるには、クエリを流した直後に取得する、あるいはウィンドウサイズや上位 N 件数のクラスター設定を調整するといった作り込みが必要で、単に API を叩くだけでは記事に載せられる観測になりませんでした。腰を据えて検証する必要があるため、別の記事に譲ります。
Search Relevance Workbench の CSV judgment アップロードとハイブリッド検索最適化も見送りました。これらは検索関連性の評価という別のテーマであり、今回のスコープ(ベクトル検索の圧縮と取得)から外れるためです。上位クエリの Amazon S3 エクスポートについても、スナップショットリポジトリの登録が前提となるため今回はスコープ外としています。
最後に
Amazon OpenSearch Service で OpenSearch 3.7 が利用可能になり、1 ビットスカラー量子化によるベクトル圧縮と、docvalue_fields を使ったベクトル取得が、マネージドドメインでも追加設定なしで動作することを確認できました。
なかでも docvalue_fields は、既存インデックスに対して検索リクエストを 1 行変えるだけで適用でき、再インデックスも不要です。ベクトルそのものをクライアントに返す用途、たとえば再ランキングやベクトルのキャッシュを自前で持っているような構成であれば、効果を確認する価値は十分にあると考えます。
一方で 1 ビットスカラー量子化は、ディスクではなくメモリに効く機能であること、インデックス時のコストが上がること、そして recall への影響を実データで測る必要があることから、本番適用の前に自分のデータセットで評価することをおすすめします。検証用のドメインは t3.small.search 1 ノードでも作成でき、今回は作成に約 19 分でした。ドメイン作成からインデックスの作成、force merge、グラフメモリの確認まで、大量データの投入とベンチマークを除けばマネジメントコンソールと OpenSearch Dashboards だけで一通り試せます。まずは小さく立てて試してみてはいかがでしょうか。









