DIVX テックブログ

catch-img

OpenSearch Serverless を NextGen にしたら、ベクトル検索が"静かに"壊れた話

mode: on_disk と、long 型に化けるインデックス 〜

同じ症状に容疑者が5人。ログの読み方で真犯人に辿り着くまで

対象読者:OpenSearch / ベクトル検索を運用する開発者・SRE。特に、Classic 世代で動いている既存設定を流用して新しい環境を増やす人(新規コレクションはデフォルトで NextGen になるため、同じ罠を踏みやすい)。
この記事でわかること:Amazon OpenSearch Serverless を NextGen 世代に切り替えたときに knn_vector マッピングで踏みやすい罠(mode / engine 非対応 → フィールドが long 型に化ける)と、その切り分け方・復旧手順。


結論から

こんにちは。DIVXの堀次です。

  • 担当しているプロダクトで、ドキュメントの類似検索(ベクトル)全文検索(キーワード) を Amazon OpenSearch Serverless(AOSS)で構築していた。
  • ある実行環境だけ、片方のエンティティの解析処理が失敗し、ベクトルインデックスが空のまま。もう片方は正常という非対称な症状。
  • 真犯人は AOSS の NextGen コレクションが knn_vectormode / engine / method.parameters を受け付けないこと。mode: "on_disk" を送っていたためインデックス作成が 400 で失敗 → 動的マッピングでベクトルフィールドが longに化け → 以降のベクトル書込みが全部 cannot be changed from [long] to [float] で落ちていた。
  • 設定を直しても直らない。 壊れたインデックスは明示的に作り直すまで残る。

タイトルの「静かに」の正体 —— この障害が気づきにくかった理由は3つある:

  1. 作成失敗が warn 止まり:インデックス作成の 400 は非ブロッキング設計で、warn ログに埋もれてアラートにならなかった(→ 5章)。
  2. UI は「完了」と言っていた:片方のエンティティは完了判定がベクトル書込みに依存せず、裏で書込みが全滅していても正常に見えた(→ 6章)。
  3. 目立つログが全部ノイズcontext deadline exceeded のような"それっぽい"エラーが先に目に入り、真犯人の 400 はサマリではなく詳細行(error_reason)にしか出ていなかった(→ 3〜4章)。

要点は以上です。以降は、そこに辿り着くまでの"犯人探し"の過程。興味のあるところから自由に読んでください。


1. 舞台:ベクトル検索 × キーワード検索

このプロダクトでは、アップロードされた画像/PDF から特徴量ベクトル(2048次元)を抽出し、AOSS の knn_vector インデックスに入れて類似検索を提供している。あわせて OCR したテキストを別インデックスに入れて全文検索もする。

flowchart LR
    U["アップロード<br/>(画像 / PDF)"] --> API["アプリ (API)"]
    API -->|"① OCR テキスト"| TIDX[("全文検索インデックス<br/>(text)")]
    API -->|"② 特徴量ベクトル<br/>(2048次元)"| VIDX[("類似検索インデックス<br/>(knn_vector)")]
    TIDX --> KW["キーワード検索 ✅ 正常"]
    VIDX --> SIM["類似検索 ❌ 壊れた"]

新しい実行環境を1つ増やしたところ、そこだけ挙動がおかしくなった。

前提:AOSS には「世代」がある(Classic / NextGen)

Amazon OpenSearch Serverless のコレクションには Classic(従来世代)NextGen(新世代) の2世代がある。ここを押さえておくと、この後の話が読みやすい。

  • 新規作成はコンソールのデフォルトが NextGen。作成画面は NextGen フローで開き、Classic にしたい場合は「Switch to Classic」で明示的に切り替える方式(AWS も新規は NextGen を推奨している)。
  • 自分の環境がどちらかの目安:NextGen はコレクショングループ(複数コレクションでキャパシティを共有する仕組み)に属する。Classic はコレクション単位の個別設定。挙動面では、knn_vector マッピングの mode / engine 指定が 400 で拒否されたら NextGen(後述)。
  • つまり「以前から動いている環境は Classic、最近デフォルトのまま作った環境は NextGen」という混在が自然に発生する。本記事はまさにこの混在——既存環境向けの設定を流用して、新しく作った NextGen 環境に持ち込んだ——ケースで踏んだ話。

2. 症状:なぜか"片方だけ"壊れる

  • エンティティ A(例:画像)… アップロードすると解析中のまま止まり、やがて 失敗扱い になる。
  • エンティティ B(例:付随ドキュメント)… 正常に解析完了
  • AOSS のインデックス画面を見ると、A も B もベクトルインデックスが 0 件。なのに全文検索用インデックスには両方データが入っている。

「テキストは入るのにベクトルが入らない」「A は失敗するのに B は成功して見える」——この非対称が最大のヒントだった(結論から言うと、これが真犯人特定の決め手になる)。


3. 容疑者は5人いた(そして4人は無実)

最初のログには、いかにも"それっぽい"エラーがいくつも並んでいて、順番に疑った。

flowchart TD
    S["症状: ベクトルが保存されない"] --> C1["容疑1<br/>レイテンシ / OCU コールドスタート"]
    S --> C2["容疑2<br/>LLM の権限"]
    S --> C3["容疑3<br/>認可 / 機能フラグのゲート"]
    S --> C4["容疑4<br/>ネットワーク到達性"]
    S --> C5["容疑5<br/>マッピング非互換"]
    C1 -->|"隣の環境は同じ共有OCUで動く"| N1["シロ"]
    C2 -->|"Playground で正常応答"| N2["シロ"]
    C3 -->|"別件だった"| N3["シロ"]
    C4 -->|"テキストは書けている = 到達OK"| N4["シロ"]
    C5 -->|"error_reason を読むと mode 非対応"| Y["🎯 真犯人"]
  1. レイテンシ / OCU コールドスタート … 全文検索側の書込みが context deadline exceeded(5秒)で落ちていた。「サーバーレスのコールドスタートだ」と思い込んだ。→ 実は非ブロッキングな別処理で、本題とは無関係だった。
  2. LLM(生成 AI)の権限 … 解析パイプラインの一部で LLM を叩いていたので疑ったが、コンソールの Playground で正常応答 → シロ
  3. 認可・機能フラグのゲート … 別の初期不具合で実在したが、今回とは別件。
  4. クロスアカウント / ネットワーク到達性 … 一見それっぽいが、テキストは書けている=到達はしている。
  5. マッピングの非互換 … ← 真犯人

転機:「同じ共有キャパシティのグループにいる別環境は、同じ設定で普通に動いている」。共有リソース(レイテンシ/OCU)が原因なら、そっちも遅くなるはず。"隣は動くのに、ここだけ落ちる" で、共有要因は全部シロになった。残るはこの環境・このインデックス固有の何か。

学び①:"A は動くのに B は落ちる" という非対称は、最強の切り分け材料。共有要因を一気に消せる。


4. 真犯人:NextGen は mode を受け付けない

落ち着いてログの詳細行(サマリではなく error_reason が出ている行)を読むと、2種類の 400 が出ていた。

(a) インデックス作成の失敗

{"type":"illegal_argument_exception",
 "reason":"Field parameter 'mode' is not supported - server : [envoy]"}

(b) ドキュメント書込みの失敗

mapper [vector] cannot be changed from type [long] to [float]

1章の前提のとおり、AOSS には Classic / NextGen の2世代がある。今回壊れた環境のコレクションは NextGen だった。一方アプリは OCU コスト削減のため、Classic では公式サポートされているディスク最適化(mode: "on_disk")のマッピングを knn_vector に送っていた:

// アプリが送っていたマッピング(Classic 前提 / ディスク最適化)
{
  "properties": {
    "vector": {
      "type": "knn_vector",
      "dimension": 2048,
      "space_type": "cosinesimil",
      "data_type": "float",
      "mode": "on_disk",
      "method": { "name": "hnsw", "engine": "faiss", "parameters": { /* ... */ } }
    }
  }
}

ところが NextGen コレクションは Simplified API で、mode / engine / method.parameters受け付けない(システムが内部で最適な設定を自動決定する)。だから (a) で CreateIndex が 400 で弾かれる。NextGen 互換はこう書く:

// NextGen が受け付けるマッピング(mode / engine / parameters なし)
{
  "properties": {
    "vector": {
      "type": "knn_vector",
      "dimension": 2048,
      "space_type": "cosinesimil",
      "method": { "name": "hnsw" }
    }
  }
}

📌 根拠(実測 + 公式):これは手元の NextGen コレクションで実際に出た 400 エラーに基づく。AWS 公式ドキュメントでも、Serverless のベクトル検索コレクションでは engine などの詳細をユーザーが指定せずシステムが自動で最適化すると説明されている(記事末尾「参考」)。


5. 二段構えの罠:long 型に化けるインデックス

問題は「作成に失敗した」だけでは終わらなかった。

CreateIndex の失敗が非ブロッキング(warn ログを出してアプリは継続)だったため、その後の書込みがインデックスを自動作成してしまう。OpenSearch の動的マッピングはフィールド型を最初に来た値から推論する。ここでベクトル格納フィールドが long(整数)型として作られてしまった。

以降、float のベクトルを書こうとすると (b) の
cannot be changed from [long] to [float](=既存フィールドの型は変えられない)で全滅。マッピングが 0 vector field のまま残る。

flowchart TD
    M["アプリが送るマッピング<br/>knn_vector + mode: on_disk"] -->|"NextGen は mode 非サポート"| E1["CreateIndex が 400<br/>Field parameter 'mode' is not supported"]
    E1 -->|"作成失敗は非ブロッキング (warn のみ)"| AC["次の書込みが<br/>インデックスを自動作成"]
    AC -->|"動的マッピングが型を推論"| L["vector フィールドが<br/>long 型に化ける"]
    L -->|"float は書けない"| E2["書込みが 400<br/>cannot be changed from long to float"]
    E2 --> Z["ベクトルが 0 件のまま"]
    Z -->|"完了判定が書込み成功に依存"| F["エンティティ A → 失敗"]
    Z -.->|"完了判定が非依存"| OK["エンティティ B → 完了 (裏では未投入)"]

学び②index 作成失敗を握りつぶす(非ブロッキング)と、書込みが動的マッピングで"別物"を作り、後続が静かに全滅する。作成失敗はちゃんと目立たせるか、書込み前に必ず正しいマッピングを保証する。


6. なぜ"片方だけ"失敗して見えたのか

ベクトル書込みはどちらのエンティティでも失敗していた。差は完了判定にあった:

  • エンティティ A … 「ベクトルの OpenSearch 書込みが成功したときだけ」完了フラグを立てる設計 → 書込み失敗 → 永遠に未完 → リトライ枯渇で 「失敗」
  • エンティティ B … 完了判定がベクトル書込みに依存しない(テキスト側だけで完了扱い)→ 「完了」表示(ただし裏では類似検索用ベクトルは入っていなかった)。

「B は完了してるから B は正常」——という思い込みが、切り分けを一瞬鈍らせた。

学び③"完了"の定義が処理ごとに違うと、同じ根本原因が別の顔で現れる。UI の"完了"=全パイプライン成功、とは限らない。


7. 修正:設定 → だけでは直らない

  1. 設定:この環境のマッピングを NextGen 互換(mode 無し)に切り替え(フラグ一つ)。同じ NextGen の別環境は最初からこの設定だった=単なる設定漏れ。
  2. 状態の修復:ここが盲点。設定を直しても、既に壊れているインデックスは自動では直らない。アプリのインデックス作成処理は「既に存在すればスキップ(マッピングは上書きしない)」設計なので、一度できてしまった long 型の壊れインデックスはそのまま残り続ける。明示的にインデックスを削除 → 正しいマッピングで再作成 → 元データ(DB)から再投入する運用コマンド(--recreate-index 相当)を1回流して解消した。

実行後、ベクトルフィールドが 0 → 1、ドキュメント数も回復し、A の解析も正常に完了した。

学び④コードを直してもデータ(状態)は直らない。破損した状態には、冪等な"作り直し"の経路を用意しておく。


8. 再発防止(同じ轍を踏まないために)

原因を直すだけでなく、「握りつぶされた作成失敗」を二度と見逃さない仕組みを足す。

  • マッピング互換性の契約テストを CI に追加:デプロイ前に、対象コレクション世代(Classic / NextGen)で送るマッピングが受理されるかを軽量に検証する。
  • CreateIndex の 400 をアラート化:作成失敗を warn で埋もれさせず、重大度を上げて検知する(今回の「静かな死」の入口はここ)。
  • 書込みメトリクスの監視failed_write_count や型不一致(cannot be changed ... type)エラーの頻度をダッシュボードに出し、"入っていない"を早期に気づく。
  • Canary / Smoke テスト:デプロイ直後に、サンプルベクトルの「書込み → 検索」まで自動で1往復させ、実データが来る前に壊れを検出する。
  • 復旧手順を Runbook 化:削除 → 正しいマッピングで再作成 → 元データから再投入、を運用者が即実行できる形で明文化しておく。

9. まとめ(持ち帰り)

  • AOSS の Classic と NextGen で knn_vector の受理仕様が違う。 NextGen(Simplified API)は mode / engine / method.parameters を拒否する。移行時は要注意。
  • index 作成失敗の握りつぶし × 動的マッピングは、"型が化けて全書込みが落ちる"静かな死をもたらす。
  • 非対称(隣は動く / ここだけ落ちる)は切り分けの最短ルート。 共有要因を一撃で消せる。
  • サマリログでなく error_reason を読む。 context deadline exceeded のような"それっぽい"ノイズに引っ張られない。
  • 設定修正 ≠ 状態修復。 壊れたインデックスは明示 recreate。

参考

お気軽にご相談ください


ご不明な点はお気軽に
お問い合わせください

サービス資料や
お役立ち資料はこちら

DIVXブログ

テックブログ タグ一覧

人気記事ランキング

関連記事