はじめに
「意味が近い文書を検索したい」──キーワードが完全一致しなくても、文章の意味的な近さで検索できる仕組みです。
本記事では Amazon OpenSearch Service + Amazon Bedrock(Titan Text Embeddings V2) を使い、最小コストで動作確認できる構成を構築しました。Lambda や API Gateway は一切使わず、AWS CLI(+ boto3)と curl だけで完結しています。実際に動かして検索結果のスコアまで確認したので、その手順を丸ごと紹介します。
意味検索(ベクトル検索)とは
テキストを数値ベクトルに変換(Embedding)し、ベクトル間の距離で「意味的な近さ」を測る検索手法です。
クエリ: 「運用の手間をかけずにイベントごとに処理を動かしたい」
↓ Embedding
[0.023, -0.147, 0.089, ...](1024次元ベクトル)
文書群と距離を比較
→ 「AWS Lambda」の文書が最も近い(= 意味が近い)
「Lambda」「サーバーレス」という単語が含まれなくても、意味が近ければヒットします。
Bedrock と OpenSearch の役割分担
手順の中で「Bedrock で Embedding を取得してから OpenSearch に POST する」という流れが出てきます。なぜ 2 つのサービスを組み合わせるのか、最初に整理しておきます。
役割の違い
| サービス | 役割 | できること |
|---|---|---|
| Amazon Bedrock(Titan Embeddings) | 「意味 → 数字」への変換係 | テキストを 1024 次元のベクトルに変換する |
| Amazon OpenSearch | 「数字の近さ」を比較する検索エンジン | ベクトルを保存し、似ているものを高速に探す |
OpenSearch 単体ではテキストの意味を理解できません。Bedrock 単体では大量の文書から似ているものを高速に探す仕組みがありません。2 つが揃って初めてキーワードが一致しなくても意味で探せる検索になります。
データ投入時の流れ
テキスト「AWS Lambda はイベント駆動型の...」
│
▼
Bedrock (Titan Embeddings)
│ テキストの意味を 1024 個の数字に変換
▼
[0.023, -0.147, 0.089, ...]
│
▼
OpenSearch に保存
{ "text": "AWS Lambda は...", "embedding": [0.023, -0.147, ...] }
検索時の流れ
クエリ「運用の手間をかけずにイベントごとに処理を動かしたい」
│
▼
Bedrock (Titan Embeddings)
│ クエリも同じモデルで数字に変換(重要!)
▼
[0.041, -0.138, 0.102, ...]
│
▼
OpenSearch k-NN 検索
│ 保存済みのベクトル全件と距離を一斉比較
▼
id=1(Lambda): 類似度 0.643 ← 近い
id=5(OpenSearch): 類似度 0.561
id=2(料理): 類似度 0.49 ← 遠い
│
▼
上位 k 件を返す
「同じモデルで変換する」がポイントです。文書を Titan Embeddings で変換しているなら、クエリも必ず同じ Titan Embeddings で変換します。モデルが異なると「ベクトルの座標系」が変わってしまい、距離の比較が意味をなさなくなります。
構成方針
| 項目 | 選択 | 理由 |
|---|---|---|
| OpenSearch | マネージドクラスター(t3.small.search 単一ノード) | Serverless は最小 OCU 課金が発生し続けるため、実験用途では無料利用枠が使えるマネージドクラスターの方が安い |
| ネットワーク | パブリックアクセス + FGAC(Basic 認証) | 最短で動作確認するため。本番ではプライベートサブネット+VPC エンドポイントへの切り替えを推奨 |
| データ投入・検索 | AWS CLI(または boto3)+ curl | Lambda 不要、その場で結果が見える |
| Embedding | Bedrock Titan Text Embeddings V2 (amazon.titan-embed-text-v2:0) |
AWS ネイティブで追加契約不要、1024 次元 |
前提条件
- AWS CLI v2.13 以上が設定済み(
aws configure)、es:*とbedrock:InvokeModelの権限がある IAM ユーザー/ロール- バージョン確認:
aws --version - v2.13 未満では
bedrock-runtimeサブコマンドが使えません(詳細は「ハマりポイント」参照) - アップグレード方法(Mac PKG 版):
curl -s "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o /tmp/AWSCLIV2.pkg sudo installer -pkg /tmp/AWSCLIV2.pkg -target /
- バージョン確認:
- Bedrock コンソール → Model access で「Amazon Titan Text Embeddings V2」を有効化済み
-
jqインストール済み(Mac ならbrew install jq)
この構成はパブリックアクセスを有効にしています。FGAC により認証情報がないとアクセスできませんが、実験が終わったら必ず削除してください。
手順
1. 共通変数の準備
REGION=$(aws configure get region)
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
DOMAIN=vector-test
INDEX=vector-test-index
MASTER_USER=admin
MASTER_PASS='ChangeMe123@' # 必ず自分の値に変更
2. OpenSearch ドメイン作成(無料利用枠対象構成)
aws opensearch create-domain \
--domain-name $DOMAIN \
--engine-version OpenSearch_2.19 \
--cluster-config InstanceType=t3.small.search,InstanceCount=1 \
--ebs-options EBSEnabled=true,VolumeType=gp3,VolumeSize=10 \
--node-to-node-encryption-options Enabled=true \
--encryption-at-rest-options Enabled=true \
--domain-endpoint-options EnforceHTTPS=true \
--advanced-security-options Enabled=true,InternalUserDatabaseEnabled=true,MasterUserOptions="{MasterUserName=${MASTER_USER},MasterUserPassword=${MASTER_PASS}}" \
--access-policies "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:${REGION}:${ACCOUNT_ID}:domain/${DOMAIN}/*\"}]}"
コマンドは数秒で返りますが、ドメインの準備にはこの後 10〜15 分かかります。
3. 作成完了待ち → エンドポイント取得
# Processing=False になるまで 30 秒間隔でポーリング
watch -n 30 'aws opensearch describe-domain --domain-name '"$DOMAIN"' \
--query "DomainStatus.Processing" --output text'
# 完了後、エンドポイントを取得
ENDPOINT=$(aws opensearch describe-domain --domain-name $DOMAIN \
--query "DomainStatus.Endpoint" --output text)
echo $ENDPOINT
実測所要時間: 約 13〜14 分(東京リージョン、2 回試して 13 分 22 秒と 13 分 47 秒)
4. k-NN インデックス作成
curl -s -XPUT "https://${ENDPOINT}/${INDEX}" \
-u ${MASTER_USER}:${MASTER_PASS} -H 'Content-Type: application/json' -d '{
"settings": {"index.knn": true},
"mappings": {
"properties": {
"text": {"type": "text"},
"embedding": {
"type": "knn_vector",
"dimension": 1024,
"method": {"name": "hnsw", "space_type": "cosinesimil", "engine": "nmslib"}
}
}
}
}'
成功すると {"acknowledged":true,"shards_acknowledged":true,"index":"vector-test-index"} が返ります。
5. テストデータ投入
ジャンルを意図的に分散させた 5 文書を用意します。
declare -A DOCS=(
["1"]="AWS Lambdaはサーバーをプロビジョニングせずにコードを実行できるコンピューティングサービスで、イベント駆動型のアーキテクチャに適しています。"
["2"]="ラム肉は赤ワインで一晩マリネしてから、クミンとガラムマサラを使ったカレーに仕立てると独特の香りが引き立ちます。"
["3"]="日本代表はワールドカップのグループステージでチュニジアと対戦し、ポゼッションサッカーで勝利を目指している。"
["4"]="セントラル・リーグは6球団で構成され、指名打者制度を採用していない伝統的なルールが特徴です。"
["5"]="OpenSearchはk-NNプラグインを使うことで、テキストのEmbeddingをもとにした近傍ベクトル検索を実現できます。"
)
for id in "${!DOCS[@]}"; do
text="${DOCS[$id]}"
echo "{\"inputText\":\"${text}\"}" > "doc_${id}.json"
aws bedrock-runtime invoke-model \
--model-id amazon.titan-embed-text-v2:0 \
--body "file://doc_${id}.json" \
--cli-binary-format raw-in-base64-out \
--region $REGION "out_${id}.json"
embed=$(jq -c '.embedding' "out_${id}.json")
curl -s -XPOST "https://${ENDPOINT}/${INDEX}/_doc/${id}" \
-u ${MASTER_USER}:${MASTER_PASS} -H 'Content-Type: application/json' \
-d "{\"text\":\"${text}\",\"embedding\":${embed}}"
echo
done
AWS CLI が v2.13 未満の場合の代替(boto3)
pip3 install boto3 後に下記 Python スクリプトで同じことができます。
import boto3, json, urllib.request, base64, ssl
REGION = "ap-northeast-1"
ENDPOINT = "<手順3で取得したエンドポイント>"
INDEX = "vector-test-index"
MASTER_USER = "admin"
MASTER_PASS = "ChangeMe123@"
DOCS = {
"1": "AWS Lambdaはサーバーをプロビジョニングせずにコードを実行できるコンピューティングサービスで、イベント駆動型のアーキテクチャに適しています。",
"2": "ラム肉は赤ワインで一晩マリネしてから、クミンとガラムマサラを使ったカレーに仕立てると独特の香りが引き立ちます。",
"3": "日本代表はワールドカップのグループステージでチュニジアと対戦し、ポゼッションサッカーで勝利を目指している。",
"4": "セントラル・リーグは6球団で構成され、指名打者制度を採用していない伝統的なルールが特徴です。",
"5": "OpenSearchはk-NNプラグインを使うことで、テキストのEmbeddingをもとにした近傍ベクトル検索を実現できます。",
}
bedrock = boto3.client("bedrock-runtime", region_name=REGION)
ctx = ssl._create_unverified_context()
creds = base64.b64encode(f"{MASTER_USER}:{MASTER_PASS}".encode()).decode()
for doc_id, text in DOCS.items():
resp = bedrock.invoke_model(
modelId="amazon.titan-embed-text-v2:0",
body=json.dumps({"inputText": text}),
contentType="application/json",
accept="application/json",
)
embedding = json.loads(resp["body"].read())["embedding"]
url = f"https://{ENDPOINT}/{INDEX}/_doc/{doc_id}"
req = urllib.request.Request(
url,
data=json.dumps({"text": text, "embedding": embedding}).encode(),
method="POST"
)
req.add_header("Authorization", f"Basic {creds}")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, context=ctx) as r:
print(doc_id, json.loads(r.read()).get("result"))
6. 意味検索の実行・検証
クエリ文はあえてキーワードをずらします。「Lambda」「サーバーレス」という単語は一切含めません。
QUERY="運用の手間をかけずにイベントごとに処理を動かしたい"
echo "{\"inputText\":\"${QUERY}\"}" > query.json
aws bedrock-runtime invoke-model \
--model-id amazon.titan-embed-text-v2:0 \
--body file://query.json \
--cli-binary-format raw-in-base64-out \
--region $REGION query_output.json
QEMBED=$(jq -c '.embedding' query_output.json)
curl -s -XPOST "https://${ENDPOINT}/${INDEX}/_search" \
-u ${MASTER_USER}:${MASTER_PASS} -H 'Content-Type: application/json' \
-d "{\"size\":3,\"_source\":[\"text\"],\"query\":{\"knn\":{\"embedding\":{\"vector\":${QEMBED},\"k\":3}}}}" | jq .
実際の検索結果
検索結果をそのまま貼ります。
{
"hits": {
"total": { "value": 5, "relation": "eq" },
"max_score": 0.64311475,
"hits": [
{
"_id": "1",
"_score": 0.64311475,
"_source": {
"text": "AWS Lambdaはサーバーをプロビジョニングせずにコードを実行できるコンピューティングサービスで、イベント駆動型のアーキテクチャに適しています。"
}
},
{
"_id": "5",
"_score": 0.56135917,
"_source": {
"text": "OpenSearchはk-NNプラグインを使うことで、テキストのEmbeddingをもとにした近傍ベクトル検索を実現できます。"
}
},
{
"_id": "4",
"_score": 0.5522306,
"_source": {
"text": "セントラル・リーグは6球団で構成され、指名打者制度を採用していない伝統的なルールが特徴です。"
}
}
]
}
}
結果の読み方
| 順位 | doc_id | スコア | ジャンル | 文書冒頭 |
|---|---|---|---|---|
| 1位 | 1 | 0.643 | 技術(Lambda) | AWS Lambda はサーバーをプロビジョニングせずに… |
| 2位 | 5 | 0.561 | 技術(OpenSearch) | OpenSearch は k-NN プラグインを使うことで… |
| 3位 | 4 | 0.552 | スポーツ(野球) | セントラル・リーグは 6 球団で… |
| 4位以下 | 2, 3 | 非表示 | 料理・サッカー | (k=3 のため非表示) |
クエリに「Lambda」「サーバーレス」という単語がまったく含まれていないにもかかわらず、id=1(AWS Lambda)がスコア 0.643 で 1 位に返ってきました。「イベントごとに処理を動かす」という意味的な近さを正しく捉えています。
また同じクエリ・同じデータで 2 回試したところ、スコアが 0.64311475 で完全一致しました。Titan Text Embeddings V2 は**出力が決定的(deterministic)**で、再現性があることも確認できました。
3 位に「野球」が入ったのは少し意外ですが、「6 球団」「ルール」など組織・規制の語彙が意外と近いベクトルになった可能性があります。1 位との差は 0.09 あり、意味検索の目的(技術文書を上位に返す)は達成されています。
7. 後片付け(課金停止・必須)
aws opensearch delete-domain --domain-name $DOMAIN
削除完了まで同様に 10〜15 分かかります(Deleted: true が返れば削除処理開始)。
スコアの読み方と注意点
スコアが0.55〜0.64に集中しているのはなぜ?
結果を見ると、1位(0.643)と3位(0.552)の差がわずか0.091しかありません。3位は「野球」の文書で、クエリとはまったく関係がなさそうなのに、なぜこれほど高いスコアが出るのでしょうか。
理由は主に2つあります。
① 高次元空間では距離が詰まる(次元の呪い)
1024次元のベクトル空間では、まったく無関係な2つのベクトルを比べても、コサイン類似度が0.4〜0.6程度になりやすい性質があります。2次元や3次元の直感とは異なり、「関係ない」文書でもそこそこのスコアが出るのが高次元ベクトルの特徴です。
② k=3 を指定しているので必ず3件返ってくる
今回は5件の文書に対して k=3 を指定しているため、スコアに関係なく上位3件が強制的に返ります。足切りがないので、本来は「遠い」文書も3位として出てきます。
スコアを正しく読む
0.643 ← 1位(Lambda) 意味的に近い ✅
0.561 ← 2位(OpenSearch) まあ近い ✅
──── ここが実質的な「関係ある/ない」の境界線 ────
0.552 ← 3位(野球) 本当はノイズ ⚠️
0.5xx ← 4位(サッカー) ノイズ
0.4xx ← 5位(料理) ノイズ
2位と3位のスコア差はわずか0.009で、実態は「2位以降はほぼ全部遠い」に近い状態です。
実運用では min_score で足切りする
本番では min_score を設定して、スコアが低い結果を除外します。
curl -s -XPOST "https://${ENDPOINT}/${INDEX}/_search" -u ${MASTER_USER}:${MASTER_PASS} -H 'Content-Type: application/json' -d '{
"size": 3,
"min_score": 0.6,
"_source": ["text"],
"query": {
"knn": {
"embedding": {
"vector": '"${QEMBED}"',
"k": 3
}
}
}
}' | jq .
min_score: 0.6 を設定すると、0.643 の Lambda だけが返り、野球・サッカー・料理は除外されます。適切な閾値はデータやユースケースによって異なるため、実際のデータで試しながら調整してください。
min_score の適切な値はモデルや文書の傾向によって変わります。まずは閾値なしで動かしてスコアの分布を確認し、「ここ以上なら関連あり」と判断できるラインを探るのが実践的なアプローチです。
ハマりポイント
AWS CLI v2.13 未満では bedrock-runtime が使えない
手順 5・6 の aws bedrock-runtime invoke-model は CLI v2.13 で追加されたコマンドです。
筆者環境(v2.2.45)では以下のエラーが出ました。
aws: error: argument command: Invalid choice, valid choices are:
...(bedrock-runtime がリストにない)
対処方法は 2 つです。
A. CLI をアップグレードする(推奨)
curl -s "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o /tmp/AWSCLIV2.pkg
sudo installer -pkg /tmp/AWSCLIV2.pkg -target /
aws --version # 2.13 以上になっていることを確認
B. boto3 を使う(CLI をアップグレードできない場合)
pip3 install boto3
手順 5 の「boto3 代替スクリプト」を実行すれば同じ結果が得られます。
コスト目安
| リソース | 料金 |
|---|---|
| t3.small.search | 約 $0.036/時間(us-east-1 基準、東京は 1〜2 割高め) |
| gp3 10GB | 約 $0.122/GB/月 |
| 24 時間起動の概算 | 約 $0.9〜1.1 |
| Bedrock Titan Embeddings(数件) | 数十銭程度 |
新規アカウントの無料利用枠を使えば 12 ヶ月間は $0 です。消化状況は AWSコンソール → Billing → 無料利用枠 で確認できます。
まとめ
- Amazon OpenSearch Service(t3.small.search)+ Bedrock Titan Text Embeddings V2 の組み合わせで、Lambda なし・curl だけでベクトル意味検索を動作確認できた
- ドメイン作成は 実測約 13〜14 分
- クエリに「Lambda」「サーバーレス」を含めなくても、意味的に近い文書(AWS Lambda の説明)が 1 位に返り、キーワード一致ではなく意味検索が動いていることを確認できた
- スコアの再現性あり(2 回試して完全一致)
- AWS CLI は v2.13 以上が必要。古い場合は boto3 で代替可能
本番化する場合は、パブリックアクセスをやめてプライベートサブネット+VPC エンドポイント+IAM 認証に変更し、Lambda でラップして API Gateway で公開する構成に移行するのがよいでしょう。
関連書籍
記事の内容をさらに深掘りしたい方におすすめの書籍です。
| 書籍 | 概要 | |
|---|---|---|
| ⭐ベスト | ベクトル検索実践入門(技術評論社) | Elasticsearch・OpenSearch・Solr のベクトル検索を体系的に解説。今回の構成の理論的な背景を深掘りするのに最適 |
| AWSではじめる生成AI(O'Reilly) | Bedrock を使った RAG アプリの構築から微調整まで。今回の記事の「次のステップ」に直結 | |
| Elasticsearch実践ガイド(インプレス) | OpenSearch の前身 Elasticsearch の実践書。インデックス設計やクエリの深掘りに |