ベクトルDBレスで、6万件のDevelopersIO記事をNumPyとBedrockでセマンティック検索してみた
2026年2月13日、DevelopersIOの公開ブログ記事数がついに60,000件の大台を突破しました。

この6万件に及ぶ技術情報をより活用しやすくするため、ベクトル検索による意味検索(セマンティック検索)と関連記事検索を実装しました。

特徴は、専用ベクトルDBを使わず、6万件の記事に対するセマンティック検索を『NumPy』と『Bedrock』で実装した点です。さらに、クエリの言語に応じてBedrock Nova 2とTitan V2の2つの埋め込みモデルを自動で使い分けるマルチモデル構成を採用しています。
本記事では、データ生成パイプラインから検索APIの実装、技術選定の経緯までを紹介します。
全体アーキテクチャ
システムは大きく2つのパートに分かれます。
利用しているAWSサービスは以下の通りです。
| コンポーネント | 技術 |
|---|---|
| 検索API | ECS Fargate (FastAPI) |
| ベクトルデータストア | Amazon DSQL / Amazon S3 (NumPy形式) |
| 記事メタデータストア | Amazon DSQL / Amazon S3 (JSON形式) |
| 検索クエリベクトル化 | Bedrock Nova 2 Embeddings / Bedrock Titan V2 Embeddings |
| AI要約生成 | Bedrock Claude Haiku 4.5 |
| CDN | CloudFront |
データ生成パイプライン
AI要約の生成
まず、記事のテキストからAIが日本語・英語の要約を生成します。この要約をベクトル化の入力として使用しました。
| 項目 | 値 |
|---|---|
| モデル | Claude Haiku 4.5 (Bedrock) |
| 処理方式 | オンデマンド(増分・再生成)/ Batch Inference(初回) |
| 入力 | Contentfulの content(本文)、excerpt(抜粋)、title |
| 出力 | 日本語要約 (summary)、英語要約 (summary_en)、英語タイトル (title_en) |
約6万件の記事を処理し、成功率は99.9%以上でした。
初回の一括処理にはBedrock Batch Inferenceを活用しています。
要約モデルの選定にあたっては、上位モデルとの比較検証を実施しています。
| モデル | 評価 | 採用理由 |
|---|---|---|
| Claude Sonnet 4.5 | 最高水準の要約精度、複雑な文脈把握 | 要約品質は最高水準だが、今回の要約タスクではHaiku 4.5との差が僅差であった |
| Claude Haiku 4.5 | 高速・安価・十分な品質 | Sonnet 4.5との比較検証の結果、技術用語の抽出や要約精度において、検索基盤を支えるのに十分な品質であると判定。採用 |
上位モデルと同等の検索精度を、より高速かつ経済的なモデルで実現できた点は、コスト面で大きなメリットでした。
埋め込みモデルの選定 — Nova 2 と Titan V2 のマルチモデル構成
ベクトル化にはAmazon Bedrock の2つの埋め込みモデルを採用し、用途に応じて使い分けています。
| モデル | モデルID | 次元数 | リージョン | 用途 |
|---|---|---|---|---|
| Nova 2 Multimodal Embeddings | amazon.nova-2-multimodal-embeddings-v1:0 |
1024 | us-east-1 | 日本語検索、関連記事 |
| Titan Text Embeddings V2 | amazon.titan-embed-text-v2:0 |
1024 | ap-northeast-1 | 短文/英語検索 |
Embeddingモデルの検索精度比較も実施しています。
Bedrockの埋め込みモデルを採用した理由は3つあります。
-
品質: 総合スコアではOSSモデル(multilingual-e5-large等)が上位でしたが、我々のセマンティック検索ではキーワード一致よりも、ユーザーが自然言語で曖昧に「困っていること」を入力するユースケースを重視しています。意図理解や表記揺れへの強さを評価した結果、Nova 2が最も適していると判断しました。
-
コスト: Bedrockバッチ推論の利用料はEC2インスタンスの実費と同水準であり、GPU等のインフラ管理が不要な点を考慮するとBedrock有利と判断しました。
-
検索時のリアルタイムベクトル化: 検索ワードをリクエスト時にベクトル化する必要がありますが、EC2やLambdaでモデルをホストする場合、モデルロード時間やコールドスタートが課題となりました。Bedrock APIであればこの問題がなく、安定したレイテンシでベクトル化が可能でした。
英語検索にTitan V2を採用した理由は、英語テキストに対する埋め込み品質がNova 2と同等以上であり、東京リージョンで利用可能なためレイテンシ面でも有利だったためです。
※2026年2月現在、Nova 2 Embeddingsは米国東部(バージニア北部)リージョン等での提供となっているため、日本語検索ではクロスリージョンでAPIを呼び出しています。Titan V2は東京リージョンで利用可能です。
クエリ言語によるモデル自動選択
検索APIでは、ユーザーの入力クエリに応じて使用するモデルとインデックスを自動的に切り替えます。
| クエリ例 | モデル | インデックス |
|---|---|---|
AWSのベストプラクティス(日本語) |
Nova 2 | nova/ja-s |
AWS Lambda best practices(英語・短文) |
Titan V2 | titan/en-s |
日本語テキストが含まれるクエリはNova 2、英単語や短文のクエリはTitan V2で処理します。
現時点ではこの2モデルの使い分けですが、今後はCohere Embed等を含めたマルチモデルでの検索精度の最適化を進めていく予定です。
関連記事検索では、言語によらず nova/en-s(英語要約ベースのNovaインデックス)を統一的に使用しています。全記事が英語要約を持つため、言語の壁なく記事間の類似度を計算できます。
ベクトル化の入力テキストは、タイトルと要約を結合した形式です(インデックス構築時)。検索時はユーザーの入力クエリをそのままベクトル化します。
インデックスは「モデル/要約タイプ」のキー構造で管理しています。
| インデックスキー | モデル | 入力テキスト | 用途 |
|---|---|---|---|
nova/ja-s |
Nova 2 | 日本語タイトル + 日本語要約(短文) | 日本語セマンティック検索 |
nova/en-s |
Nova 2 | 英語タイトル + 英語要約 | 関連記事検索 |
titan/en-s |
Titan V2 | 英語タイトル + 英語要約 | 英語セマンティック検索 |
# nova/ja-s(日本語記事のみ、短文要約)
text = f"{記事タイトル}\n{日本語要約(1文)}"
# nova/en-s, titan/en-s(全記事の英訳済み要約)
text = f"{英語タイトル}\n{英語要約}"
日本語検索用の nova/ja-s では、3行の箇条書き要約ではなく1文(約200文字)の短文要約を入力としています。短文要約のほうが検索クエリとの意味的な距離が近くなり、検索精度が向上する傾向が確認されたためです。
ベクトルデータの保存 — 3つの役割分担
ベクトル化されたデータは、用途に応じて3箇所に保存しています。
Bedrock (ベクトル化)
├── Amazon S3 Vectors (ベクトルDB、ネイティブベクトル検索用)
└── Amazon DSQL (blogpost_vectors テーブル、BYTEA形式)
↓ 定期エクスポート
S3 (NumPy形式: vectors.npy + index_map.json.gz)
| 保存先 | 役割 |
|---|---|
| Amazon S3 Vectors | モデル評価用途、兼バックアップ |
| Amazon DSQL | ベクトルデータの正(BYTEA格納)、差分管理・多言語管理 |
| Amazon S3 (NumPy) | API検索用の高速リードキャッシュ(ECSにオンメモリ展開) |
Amazon S3 Vectors
当初はセマンティック検索にもS3 Vectorsを利用する予定でした。しかし、フィルタ機能は備えるものの、クエリあたりの取得件数が最大100件に制限されており、約6万件のインデックスに対するテキスト検索用途には不十分でした。
現在S3 Vectorsは、モデル性能や特性の評価用途として廉価に利用できる環境として、またベクトルデータのバックアップとしても活用しています。
Amazon DSQL
ベクトルデータの正(マスター)はAmazon DSQLに格納しています。差分管理や複数モデル・言語のインデックスを一元管理する役割を担います。DSQLの分散書き込み性能やマルチリージョンでの強い一貫性は、正解データの保持基盤として非常に信頼性が高く、この役割に最適です。
Amazon DSQLはPostgreSQL互換ですが、現時点ではpgvectorなどのベクトル検索拡張をサポートしていません。そのため、ベクトルデータはBYTEA型でバイナリ格納しています。
CREATE TABLE blogpost_vectors (
article_id VARCHAR(50) NOT NULL,
language VARCHAR(10) NOT NULL,
vector BYTEA NOT NULL, -- 1024次元 × float32 = 4,096 bytes
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (article_id, language)
);
1024次元のfloat32ベクトルは4,096バイトのバイナリとなり、JSON形式(数値をテキスト表現)と比較して格納効率が大幅に優れています。updated_atによる日付管理で差分エクスポートを実現し、languageカラムを複合主キーに含めることで、複数言語のインデックスを同一テーブルで管理できる設計としています。
NumPyエクスポート時は、前回エクスポート以降に updated_at が更新されたレコードのみを取得し、既存の .npy 行列に差分マージする方式です。約6万件のフルスキャンを毎回行う必要がなく、数十〜数百件の差分であれば数秒で完了します。
初回のフルエクスポート処理の流れは以下の通りです。
- DSQLからインデックス別に全ベクトルを取得(バッチサイズ2,000記事)
- BYTEAを
np.frombuffer(bytes, dtype=np.float32)で復元 np.stack()で (N, 1024) の行列を構築vectors.npy+index_map.json.gzとしてS3にアップロード
S3 NumPy形式
Python上でベクトル検索・類似度計算を行う手段として、Pythonの代表的な数値計算ライブラリであるNumPyを採用しました。DSQLに格納したベクトルデータをNumPy配列に変換し、.npy形式でS3に保存するエクスポート処理を用意しました。
my-vector-index/
├── nova/
│ ├── ja-s/
│ │ ├── vectors.npy # shape: (56910, 1024), float32, 223MB
│ │ └── index_map.json.gz # {"article_id": array_index, ...}
│ └── en-s/
│ ├── vectors.npy # shape: (60008, 1024), float32, 245MB
│ └── index_map.json.gz
└── titan/
└── en-s/
├── vectors.npy # shape: (60008, 1024), float32, 246MB
└── index_map.json.gz
ECSタスクは起動時に環境変数 ACTIVE_INDEXES で指定された3つのインデックスをS3からダウンロードし、np.load()で即座に検索可能な状態にします。
検索APIの実装
起動時のデータロード
ECSタスク起動時に、S3から3つのインデックスのベクトルデータをメモリにプリロードします。
# ACTIVE_INDEXES = "nova/ja-s,nova/en-s,titan/en-s"
for index_key in active_indexes:
matrix = np.load(f'/tmp/vectors_{index_key}.npy', mmap_mode='r')
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
normalized = (matrix / norms).astype(np.float32)
起動時に全ベクトルのL2ノルムを計算し、正規化済み行列を保持します。正規化済みベクトル同士の内積 = コサイン類似度となるため、検索時は単純な行列積(dot product)のみで済みます。
| インデックス | 件数 | ファイルサイズ | 用途 |
|---|---|---|---|
| nova/ja-s | 56,910件 | 223MB | 日本語検索 |
| nova/en-s | 60,008件 | 245MB | 関連記事 |
| titan/en-s | 60,008件 | 246MB | 英語検索 |
3インデックス合計で約750MBのメモリを使用します。
コサイン類似度検索
検索ワードをクエリ言語に応じたBedrockモデル(Nova 2またはTitan V2)でベクトル化し、対応するインデックスの全記事ベクトルとの類似度をNumPyの行列演算で一括計算します。
q_norm = query_vector / np.linalg.norm(query_vector)
similarities = normalized @ q_norm # 全件の類似度を1回の行列積で計算
distances = 1.0 - similarities # コサイン距離に変換(0に近いほど類似)
top_indices = np.argpartition(distances, top_k)[:top_k] # 上位K件を高速選択
top_indices = top_indices[np.argsort(distances[top_indices])] # 距離順にソート
約60,000件 × 1024次元の行列積は数ミリ秒で完了します。
複合検索 — 事前フィルタ方式
著者やタグなど特定の条件を指定した場合、ベクトル検索の前に対象記事を限定し、限定された記事群のみに対して類似度を計算します。
全記事ベクトル (60,000件)
↓ 著者・タグなどで事前フィルタ
対象記事ベクトル (例: 500件)
↓ 対象のみ抽出して行列積
類似度順にソート → 結果返却
一般的なベクトルDBでよくある「類似度順で上位100件を取得した後にフィルタリングする(事後フィルタ)」方式では、条件で絞り込んだ結果が極端に少なくなる(最悪0件になる)問題が起きますが、今回の仕組み(事前フィルタ)を採用することで、事後フィルタでは漏れていた記事も確実にヒットし、計算量も対象数に比例して削減できました。
実装イメージ:
# 1. メタデータから条件に合致する記事IDを抽出
candidate_ids = get_filtered_article_ids(author="<author_id>", tag="AWS")
# 2. 記事IDをNumPy配列の行インデックスに変換
indices = []
valid_ids = []
for cid in candidate_ids:
idx = id_to_idx.get(cid)
if idx is not None:
indices.append(idx)
valid_ids.append(cid)
# 3. 対象行のみスライスして行列積(全件計算を回避)
similarities = normalized[indices] @ q_norm
distances = 1.0 - similarities
# 4. 距離順にソートして結果返却
order = np.argsort(distances)
results = [{'key': valid_ids[i], 'distance': float(distances[i])} for i in order]
関連記事検索
記事詳細ページで「関連記事」を表示するための検索です。検索ワードの代わりに、基準記事のベクトルを使います。
| 検索方式 | 説明 |
|---|---|
tags |
AIタグの共通度 × relevance_score で類似度計算。時間減衰あり |
vector |
基準記事ベクトルと全記事ベクトルのコサイン類似度 |
hybrid |
タグスコア(40%)+ ベクトルスコア(60%)の加重平均 |
author+vector |
同一著者の記事に限定してベクトル類似度計算 |
データ更新フロー
毎時増分更新
Contentful (記事更新)
→ Webhook → Step Functions
→ AI要約生成 (Claude Haiku 4.5)
→ ベクトル化 (Nova 2 / Titan V2 Embeddings)
→ DSQL書き込み + S3 Vectors登録
→ NumPyエクスポート (S3)
ECS上のバックグラウンド更新
ECSタスクは長時間稼働する可能性があるため、S3のデータ変更をバックグラウンドで検知・反映する仕組みとしました。
| 対象 | チェック間隔 | 検知方法 |
|---|---|---|
| S3キャッシュ (master-data) | 10分 | head_object LastModified |
| S3キャッシュ (delta/差分更新データ) | 10分 | head_object LastModified |
| ベクトルデータ (vectors.npy) | 1時間 | head_object LastModified |
更新は、グローバル変数の参照先をアトミックに差し替える方式です。バックグラウンドで新しいデータを完全に構築してから参照先を切り替えるため、リクエスト処理中のプロセスは旧データを安全に使い続け、次のリクエストからは瞬時に新データが参照されます。
ヘルスチェック
ヘルスチェック (GET /health) でキャッシュとベクトルデータのロード状態を確認します。
// 正常時 (200)
{"status": "ok", "cache_ready": true, "vectors_ready": true}
// 未ロード時 (503)
{"status": "degraded", "cache_ready": false, "vectors_ready": false,
"missing_keys": ["meta_by_id", ...],
"missing_vectors": ["nova/ja-s", "nova/en-s", "titan/en-s"]}
503応答時はECSヘルスチェックでunhealthy判定となり、トラフィックから除外されます。
パフォーマンス
| 指標 | 値 |
|---|---|
| セマンティック検索・日本語 (TTFB) | 380ms |
| セマンティック検索・英語 (TTFB) | 204ms |
| 関連記事・ベクトル検索 (TTFB) | 36ms |
| メモリ使用量 | ~2,200MB (キャッシュ + ベクトル×3) |
※東京リージョンのCloudShellから計測
日本語検索(380ms)の内訳を分解すると、NumPy演算自体のコストはごく僅かであることがわかります。
| 処理工程 | 所要時間(目安) | 備考 |
|---|---|---|
| クエリのベクトル化 (Bedrock API) | ~350ms | クロスリージョン呼び出しのRTT含む |
| NumPy類似度計算 (6万件) | < 10ms | オンメモリ行列積の強み |
| メタデータ結合・整形 | ~20ms | |
| 合計 (TTFB) | ~380ms |
英語検索(204ms)はTitan V2を東京リージョンで呼び出すため、クロスリージョンのNova 2を使う日本語検索より低レイテンシです。関連記事検索(36ms)はベクトル化済みデータ同士のオンメモリ演算のみで高速です。Nova 2が東京リージョンで利用可能になれば、日本語検索のレイテンシ改善も見込めます。
技術的なポイント
なぜ専用ベクトルDBを使わないのか
DevelopersIOの検索基盤は、これまでも段階的に進化してきました。過去にはAmazon Elasticsearch Service(現OpenSearch)を構築・運用したり、フルマネージドな検索SaaSを利用した時代もありました。
強力な検索SaaSは非常に有用ですが、我々の「頻繁なインデックス再構築」という運用要件と「レコード数に応じた課金体系」を照らし合わせると、コストの最適化や運用のシンプル化において検討の余地がありました。
翻って今回のセマンティック検索を設計する際、「記事数が約60,000件(1024次元でも数百MB程度)である」というデータ規模に着目しました。これを高コストになりがちな専用ベクトルDBや外部SaaSに入れるのではなく、既存のFargateのメモリ上にNumPy配列として載せてしまうのが、最もシンプルで理にかなっていると判断しました。
専用DBを見送った具体的なメリットは以下の通りです。
- インデックス更新がシンプルかつ無料: S3から新しい
.npyをダウンロードし、Python上の変数を差し替えるだけでアトミックな更新(サービスダウンタイムなしの切り替え)が完了します。SaaSのように一時的なレコード増加による追加コストを気にする必要がありません - 起動速度とスケーリングの安全性: Amazon Aurora DSQLは超高スケーラブルな分散書き込みやマルチリージョンでの一貫性において革新的なサービスですが、ECSタスクの起動時に数万件を一括ロードする用途では、S3上の単一バイナリ(
.npy)をメモリマッピングする方がプロトコルオーバーヘッドがなく高速です。オートスケーリングによるタスク増殖時にデータベースへ全件SELECTが集中するのを防ぎ、DSQLを本来の「正解データ保持・差分管理」という重責に専念させるアーキテクチャとしています - 十分なパフォーマンス: 3つのインデックスを保持してもメモリに収まるサイズ(~750MB)であり、NumPyの行列積で6万件を全件計算しても数ミリ秒で完了します。専用DBへのクエリ(ネットワークRTT)を挟むより、手元のCPUキャッシュとNumPyで行列演算する方がレイテンシを極限まで削れます
- モデル選択の柔軟性: インデックスが「モデル/要約タイプ」のキー構造で管理されているため、新しい埋め込みモデルや要約形式の追加が容易です。環境変数の変更だけで使用するインデックスを切り替えられます
- インフラ運用の最小化: データストアはすでに利用しているS3とDSQLのみ。新たな専用ベクトルDBの運用コンポーネントや障害点を増やさずに済みました
今後、記事数が数十万件規模に達しても、リソース増強や言語ごとのタスク分割で対応できるため、将来的な拡張性も十分に確保されています。
参考: 月額コスト概算
検索1日1万回、月間1,000件の記事更新を想定した場合の概算です。
| コンポーネント | 月額 |
|---|---|
| Fargate Spot 2タスク (1vCPU / 4GB) | ~$28 |
| Bedrock(検索ベクトル化 + AI要約 + 記事ベクトル化) | ~$6 |
| S3 / DSQL | ~$2 |
| 合計 | ~$36 |
既存のフロントエンドコンテナに相乗りさせる想定のため、追加コストはメモリ増分程度、実質 月額$10前後で収まる計算です。
事前フィルタによる効率化
S3 Vectorsでは取得件数が最大100件に制限されるため、ベクトル検索結果100件からのフィルタリングでは、著者やタグで絞り込んだ際に期待した結果が得にくい状況が確認されました。
期間や言語ごとにインデックスを分割する方法も検討しましたが、性能上限の大幅な改善は見込めない一方で、運用の複雑化を招く懸念がありました。
そこで、Fargate上のNumPyによるオンメモリ方式を採用し、先に対象記事をメタデータで限定してからベクトル計算を行う事前フィルタ方式としました。全件に対して行列演算を実行できるため、フィルタ条件に合致する記事を漏れなく検索でき、計算量も候補数に比例して削減されます。Fargateの利用コストも十分廉価であり、実用的な選択となりました。
オンメモリ全文検索 — セマンティック検索との併用
セマンティック検索の実装後、「全文検索も欲しい」というリクエストをいただきました。ベクトル検索は意味的な類似度に基づくため、例えば「Lambda」と検索しても、タイトルに「Lambda」を含む記事が必ずしも上位に来るとは限りません。サービス名や技術名をピンポイントで検索するユースケースでは、テキストの直接マッチが期待されます。
ここで活きたのが、既にオンメモリに展開済みのS3キャッシュデータです。記事メタデータ(タイトル)とAI要約(英語タイトル・日本語要約・英語要約)は、ベクトル検索の事前フィルタ用に全件メモリ上に保持しています。このデータをそのまま部分一致検索に流用することで、追加のインフラコストやDB問い合わせなしに全文検索を実現できました。
検索結果の3層マージ:
検索ワードが一単語の場合、テキストマッチ結果をベクトル検索結果より上位に表示します。
| 優先度 | マッチ対象 | スコア | ソート |
|---|---|---|---|
| 1 | タイトル(日本語 / 英語) | 100 | 公開日降順 |
| 2 | 要約(日本語 / 英語) | 99 | 公開日降順 |
| 3 | ベクトル検索(セマンティック) | 類似度に基づく | 類似度順 |
if _is_single_word(q):
title_ids, summary_ids = _text_match_ids(q, meta_by_id, summary_by_id, lang)
# タイトルマッチ → 要約マッチ → ベクトル検索結果の順にマージ
約60,000件のタイトル・要約に対する部分一致検索は、Pythonの文字列 in 演算(C実装)で5〜15ms程度で完了します。Bedrockへの追加API呼び出しも発生しません。
既にオンメモリに載せているデータを別の用途に再利用する、というのはシンプルなアプローチですが、「セマンティック検索と全文検索の併用」をコスト追加なしで実現できた点は、このアーキテクチャの副次的なメリットと言えます。
まとめ
本記事では、専用のベクトルデータベースを使わず、約6万件の記事に対するセマンティック検索と関連記事検索を実現した構成を紹介させていただきました。
このアーキテクチャは、データ規模に対する「管理コストの最小化」と「実行性能」を両立させた、現時点における合理的な帰結であると考えています。 既存のS3やECS(Fargate)に、新たに採用したDSQLを組み合わせることで、月額の追加コストは$36前後、関連記事検索も36ms前後という低レイテンシを実現できました。
クエリ言語に応じてNova 2とTitan V2を自動で使い分けるマルチモデル構成により、各言語に最適な検索精度を実現しつつ、インデックスの追加・切替は環境変数の変更だけで完結します。
1024次元のベクトルで数十万件規模であれば、Fargate上のNumPy演算のみで十分にスケールします。新たな障害点や固定費を増やさず、慣れ親しんだAWSサービスのビルディングブロックを組み合わせることで、実用的な検索基盤を構築できたのは大きなメリットでした。
実用的な検索基盤を構築する際の一助となれば幸いです。
一方で、昨年末の AWS re:Invent 2025 (DAT441等) では、Amazon Aurora DSQLの将来的なロードマップとして、ネイティブなベクトル検索や生成AIサポートへの言及もありました。
今後、DSQLの進化が楽しみですが、ネイティブなベクトル検索がリリースされた暁には、今回のNumPyのロジックとの比較、コストや性能差の確認を試みてみたいと思います。








