大量の書類を複雑な条件で検索する基盤として OpenSearch を導入した過程と苦慮したポイント – LayerX エンジニアブログ

こんにちは。2025 年 4 月に LayerX に新卒入社し、請求書発行チームでエンジニアをしている @tak848_ です。
学生時代に一人で出た直近の ISUCON14 は、計測ツールを同じ VPC の EC2 上に立てていたことで失格になりました。最近は、プロダクト仕様や今回の検索、 PDF をはじめとした様々な深みに対峙しすぎて、「深淵の tak」などと呼ばれています。

この記事では、バクラク請求書発行の書類検索基盤を、SQL 直叩きから、OpenSearch を利用する形式へとリプレイスした際にさまざまな思考・苦慮ポイントがあったのでそれを共有していきたいと思います!

バクラク請求書発行の紹介とリプレイス前の課題

プロダクト紹介

「バクラク請求書発行」(以下、発行)は、弊社が開発しているプロダクトの一つで、会社が発行するあらゆる書類の電子発行を Web 上で簡単にできるシステムです。
請求書、納品書、見積書などのあらゆる書類の作成、送付、郵送代行、保管ができ、直近リリースしたバクラク債権管理との連携によって、入金消込業務まで一気通貫の対応が可能です。
その帳票に関しては、お客様の業務に合わせて、金額や支払期日といったバクラクで用意されている項目に加えて、案件番号やプロジェクト名、その日付などのお客様が独自の項目を作り書類に表示・紐付けすることも出来るようになっています。

発行では書類の一覧ページがあり、発行前の帳票から発行後の帳票まで、全ての書類を閲覧・そして書類番号や書類日付といった基本項目から、送付方法や送付状況といった項目など、25 項目を超える柔軟な検索条件を使用でき、お客様の業務に合わせた様々な方法で検索が出来ます。

課題

従来、帳票の一覧検索は完全に SQL の SELECT query のみで実装されており、以下の課題がありました。

課題 1: カスタム項目による検索ができない

お客様によって任意の項目を定義できますが、従来は作成される PDF 書類または書類のページに直接表示することしかできませんでした。というのも、データ構造的には書類の table に field id と値の object 配列として JSON の状態で保存されていたためです(いわゆるシリアライズ LOB)。JSON の検索サポート・インデックス対応も近年は RDB レベルで存在はしますが、クエリが複雑になる上にチューニングは困難になります。このままでは、案件やプロジェクト名を書類に紐付けているお客様をはじめとし、非常に要望数の多い待望の機能を実現できませんでした。

課題 2: 帳票件数・検索条件によるパフォーマンスの低下

25 個以上もある検索項目の多さと、一部データの持ち方の都合により、検索パターンがテナントごとにバラバラなため、全てのケースでオプティマイザが上手く最適な検索をできるようチューニングすることは極めて困難となっていました。すでに一部の検索では最大数十秒ほどかかるケースも発生していました。また、検証環境で書類件数が多い環境の試験もしているのですが純粋に検索速度が悪化していた上、プロダクトの性質上年数がたつごとに、テナント辺りの書類件数も線形かそれ以上に増えていくため、初期は高速でも運用が進むほど体験が悪化することが目に見えていました。

アーキテクチャ設計と技術選定

技術選定の方針

今回の発行の要件では、Web 検索のような「関連度順」のスコアリングは不要で、RDBMS のWHERE句のように条件に完全に一致するか否かを高速に判定する純粋なフィルタリングが目的でした。
OpenSearch のクエリは、フリーワード検索のような、全文から関連度の高い Document をスコア付きで抽出する検索もできますが、今回はスコア計算を行わないfilterコンテキストで実行することで、関連度スコアの計算オーバーヘッドを完全に排除し、SQL の WHERE 句に近い感覚で高速なフィルタリングを実現しました。

Aurora MySQL だけで同じことを実現しようとすると、検索項目の組み合わせごとに複合インデックスを貼る必要があり、カスタム項目や NULL ハンドリングが入った瞬間に設計が破綻します。インデックスを一つ増やすたびに INSERT が遅くなる問題もあります。検索パターンがテナントごとに異なる結果、オプティマイザがフルスキャンを選んで 30 秒級のレスポンスタイムになったり、COUNT(*)クエリが全件走査になったりするケースも珍しくありませんでした。

OpenSearch では「転置インデックスを先に作る → クエリ時に分散並列で絞り込む」という仕組みを取れるため、カスタム項目が増えても性能を維持しつつ、複雑な検索条件でも高速な検索が実現できると判断しました。

そして以下の記事にもあるように、事業部として全文検索エンジンは基本的には OpenSearch Serverless を活用する方針なので、基盤はこれに則り OpenSearch Serverless を使う方針としました。

tech.layerx.co.jp

OpenSearch に移行する際のデータ構造考慮

お客様独自の項目検索

シリアライズ LOB として JSON 保存されていたカスタム項目を効率的に検索するため、OpenSearch のnestedデータ型を活用しました。

nested データ型とは: 配列内の各オブジェクトを独立したドキュメントとして扱うデータ型。通常の object 型では配列内の複数オブジェクトの値が混ざってしまいますが、nested を使うことで「field_id が A かつ value が B」という正確な検索が可能になります。

以下はサンプルです。

"fields": {
  "type": "nested",
  "properties": {
    "field_id": { "type": "keyword" },
    "value_string": { "type": "text", "analyzer": "ngram_analyzer" },
    "value_number": { "type": "scaled_float", "scaling_factor": 1000 }
  }
}

これにより、JSON カラムでは不可能だった複雑な条件での絞り込みを、nestedクエリをfilterコンテキストで実行することで、高速に実現できる見通しが立ちました。
書類項目は別 table で管理しており、その ID が入ります。
例えば、文字列のカスタム型として、案件番号に hoge プロジェクトの 2025 年の設定されているものを検索したいときのクエリは以下のようになります(フィールド名・値は例です)。

{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "tenant_id": "テナントID"
          }
        },
        {
          "nested": {
            "path": "fields",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "fields.field_id": "案件番号フィールドのID"
                    }
                  },
                  {
                    "match_phrase": {
                      "fields.value_string": "2025 hoge"
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  }
}

このクエリは以下のように動作します:

  1. nestedクエリでfields配列内を検索
  2. field_idで対象のカスタム項目を特定(例:案件番号)
  3. match_phraseで値を部分一致検索(ngram analyzer による高速検索)

複数のカスタム項目を組み合わせた検索も、nested クエリを複数指定することで実現できます。

データ構造に起因する更新処理の負荷

設計をしていく中で浮き彫りになった課題がありました。発行では、書類周りの管理をする Service と送付先は、マイクロサービス上の別 Service として分かれており、DB の table も別で別れています。
書類には、書類ラベルと呼ばれるいわゆる「タグ」を付与できるのですが、従来仕様では、「送付先」マスタにも直接「書類ラベル」を紐付ける、検索をすることが出来ました。*1
ただ、移行以前の仕様では、「その時点」の送付先の書類ラベルを仮想的に書類に紐付けるという仕様でした。つまり、この仕様のままだと、送付先のラベルを 1 つ変更するだけで、その送付先に紐づくすべての書類の情報が変化する、ということになります。*2

このそもそも SQL のクエリとして複雑かつ非効率なものとなっていて、従来の SQL 検索においてもパフォーマンス上の懸念点ではありましたが、OpenSearch に移行する上では、さらに致命的な問題でした。OpenSearch のデータは DB から非正規化した状態で登録されるため、書類 1 件 1 件の OpenSearch Document 上にそれぞれのラベルの情報を紐付ける必要が原則あります。
もしこの仕様のまま移行すれば、ラベル変更のたびに大量のドキュメントを再インデックスする処理が走り、システムに計り知れない負荷をかけることになります。

そのため、OpenSearch へのリプレイスに先立ち、「書類作成・編集時点のラベルをスナップショットとして書類に保存する」という仕様変更を行いました*3。これは「作成時点のラベルを維持してほしい」というお客様からのご要望にも沿うものでもありました。
この仕様変更を事前にお客様にアナウンスした上で実施した結果、SQL 検索のパフォーマンスも一部改善されましたが、本丸は来る OpenSearch 化に備えることでした。この一連の流れが、検索基盤全体のリアーキテクチャへと繋がっていきます。

検索アーキテクチャ

最終的な検索のアーキテクチャは、それぞれの DB の得意分野を活かすハイブリッド構成としました。

  • OpenSearch: filterクエリによる高速な絞り込み(ID 取得)に特化。
  • MySQL: OpenSearch から得た ID に基づき、正確な最新データを取得(Single Source of Truth)。

これによって、OpenSearch と MySQL とのデータに差分が無いことを維持するのが当然の使命ではありますが、万が一障害などがあり検索と実際のデータに一時的に差分が発生してしまったとしても、表示されているデータは正しい、という状態を作っています。

sequenceDiagram
    participant Client as Frontend
    participant GraphQL as API Gateway
    participant DocumentService as Backend Service
    participant OpenSearch
    participant MySQL

    Client->>GraphQL: 書類一覧を検索(絞り込み条件)
    GraphQL->>DocumentService: 検索リクエスト

    alt OpenSearchで検索可能な条件
        DocumentService->>OpenSearch: 条件に合う書類IDを検索 (Filterクエリ)
        OpenSearch-->>DocumentService: 書類IDのリスト (ソート済み)
        DocumentService->>MySQL: IDリストを元に書類データを取得
        MySQL-->>DocumentService: 正確な書類データ
    else SQLでのみ検索すべき条件
        DocumentService->>MySQL: 従来のSQLで検索
        MySQL-->>DocumentService: 書類データ
    end

    DocumentService-->>GraphQL: 検索結果
    GraphQL-->>Client: 書類一覧

書類一覧においては全て OpenSearch に寄せていますが、バクラク申請との連携使用時に申請側から使われる書類取得で使うときのパラメータなど一部、OpenSearch 対応が難しい項目があったため、既存の機構に簡単にスイッチできる設計としています。
また、移行時は、OpenSearch と SQL の双方で検索し、差分があったら警告を出す実装も入れて、デグレが発生していないかの検証を行っていました。

OpenSearch Serverless 運用で直面した試練

試練 1: refresh_interval による 10 秒の検索反映遅延

OpenSearch Serverless では、インデックスのrefresh_interval最小 10 秒に固定されており、変更できません。これは、書類を作成・更新してから検索結果に反映されるまで最大 10 秒かかることを意味します。

docs.aws.amazon.com

The refresh interval for indexes in vector search collections is approximately 60 seconds. The refresh interval for indexes in search and time series collections is approximately 10 seconds.

実装が進んだ段階でこの制約に気づき、対応方針を検討しました。マネージドクラスターへの移行も選択肢として検討しました。
さらに調査したところmax_result_window(ページネーション上限)も Serverless では 10,000 件固定で変更不可であることが判明しました。ただし、試練 4 にも記述しましたが、この制約はPoint In Time (PIT) APIを使うことで技術的に解決できる見通しが立ちました。

最終的に、Serverless のまま UI/UX 側の改善で対応する方針に決定しました。理由は以下の通りです:

  • Serverless の運用メリット(自動スケーリング、シャード管理不要)が大きい
  • お客様の実際の業務フローを分析すると、書類作成後即座に検索することは稀
  • 検索速度が数十秒から 1 秒未満になった改善効果が、10 秒の反映遅延を大きく上回る(書類の情報を後から確認したり、探したりするのにメインに使用する)

実装としては、操作後一覧の表示に違和感を感じやすい場所で、「xx しました」系のアラート表示時間を長くし、「検索結果に反映されるまで 10 秒ほどかかります」とシンプルに伝えることで、お客様に何が起きているかを明確にし、混乱を防ぐようにしました。ユーザーが次に検索を行う可能性を考慮して表示時間を調整しています。そして、各操作後に自動で書類更新をかけるようにした上に、自動更新が終了してもお客様が任意のタイミングで書類一覧を更新できるようボタンを追加し、お客様が任意のタイミングで最新の検索結果を取得できるようにしました。
対処的な面も強いですが、ここはチームでもまだ課題感として持ってはいるので良い経験を引き続き追求していこうと思います。

試練 2: インデックス更新経路の信頼性と競合の問題

課題

当初の実装では、インデックス更新経路が、DB のトランザクション内で直接更新する同期的な経路と、送付処理などで使われるイベント経由の非同期的な経路の 2 つに分岐していました。

これには 2 つの大きな問題がありました。

  1. トランザクション失敗時のロールバック: 同期経路でインデックス更新に失敗した場合(例: OpenSearch の一時的な障害)、DB のトランザクション全体がロールバックされてしまうリスクがありました。index に部分成功してしまったケースなどが特に対応が大変です。また、RPC が失敗した際のインデックス更新漏れを追跡するのも困難でした。
  2. インデックスの競合 (Race Condition): 同期と非同期の経路が混在している、あるいは複数のイベントが同時に発行されることにより、インデックス更新処理が並走し、古いデータで上書きされてしまう競合状態が発生していました。

解決策としてのイベント駆動一本化とロック導入
これらの問題を解決するため、2 つの段階的な意思決定を行いました。

1. インデックス更新経路のイベント駆動への一本化
まず、同期更新のリスクと更新漏れの問題を解決するため、すべてのインデックス更新を非同期のイベント駆動に一本化しました。これにより、OpenSearch の障害が DB トランザクションに影響を与えることがなくなり、SQS の再試行メカニズムによってインデックス処理の信頼性が向上しました。

As-is(当初実装)
graph TB
    subgraph "Backend Service"
        UseCases["ビジネスロジック"]
        DirectIndex["同期インデックス更新
(トランザクション内)"] EventPublisher["イベント発行"] end subgraph "Event Bus" EventBridge end subgraph "Index Worker" BatchIndex["非同期インデックス更新"] end subgraph "Storage" MySQL OpenSearch end UseCases -->|書き込み| MySQL UseCases -->|書き込み| DirectIndex --> OpenSearch UseCases -->|失敗時など| EventPublisher --> EventBridge --> BatchIndex --> OpenSearch classDef deprecated fill:#ffe5e5,stroke:#ff4d4f,stroke-width:2px; class DirectIndex deprecated;
To-be(現在)
graph TB
    subgraph "Backend Service"
        UseCases_New["ビジネスロジック"]
        EventPublisher_New["イベント発行"]
    end
    subgraph "Event Bus"
        EventBridge_New["EventBridge"]
    end
    subgraph "Index Worker"
        BatchIndex_New["非同期インデックス更新"]
    end
    subgraph "Storage"
        MySQL_New["MySQL"]
        OpenSearch_New["OpenSearch"]
    end

    UseCases_New -->|書き込み| MySQL_New
    UseCases_New -->|常に| EventPublisher_New --> EventBridge_New --> BatchIndex_New --> OpenSearch_New

index 時のロック導入

経路を一本化しても、複数のイベントが同時に処理されることによる競合状態のリスクは依然として残ります。この競合状態を完全に解決する唯一の方法として、DynamoDB を利用したテナント単位の分散ロックを導入しました。インデックス更新処理を開始する前に必ずロックを取得することで、特定のテナントに対する更新は常に直列に実行されるようになり、データ整合性を完全に担保しました。ロックの機構は AWS のマネージドサービスで信頼性が高く、また共通基盤に用意されていた DynamoDB を利用したものを活用しました。

10 秒の遅延が前提となっている設計の今、DB トランザクションに入れ込む危険な実装をしてまで当初は index の遅延を防ごうとしていましたが、今やこのロック待ち程度の遅延は検証の上で許容できると判断しました。

graph TB
        Handler["イベント受信"] --> Lock["DynamoDBでロック取得"]
        Lock -- 成功 --> Index["OpenSearchインデックス更新"] --> Unlock["ロック解放"]
        Lock -- 失敗 --> Handler

ロック単位は書類 1 件単位ではなく、インデックス処理のうちデータ取得から実際に OpenSearch に登録するまでの処理系に対してテナント単位でかけています。そのため、処理系内では並列で書き込むようにすることで、書類の一括操作が発生しても、書類単位で event 発行はせず、ある程度の件数でまとめて index します。
今モニタリングしている限りも event のつまりは現状無く、大量件数の操作でも 10 秒遅延はある前提の元問題なく動いています。

試練 3: API スロットリング (429 エラー) への対処

OpenSearch に Index 登録する際、全テナントのデータを投入するときや、その他でも散発的に429 Too Many Requestsエラーが散発的に発生していました。
一つの検証による推測では、OpenSearch Serverless がトラフィック急増に対してリアクティブにスケールして OCU が増加するものの、そのスケール処理が完了するまでの時間差が若干長く一時的にキャパシティが不足するために発生していることが多かったです。

この問題に対し、exponential backoff を用いたリトライ機構をアプリケーション側に実装することで、Serverless のスケーリング特性に起因する一時的なエラーを乗り越え、安定したインデックス更新を実現しています。
何度も失敗した場合も、上記の改善で全てイベント経由としたため、再度リドライブされて整合性がとれる設計となっています。
refresh_interval による 10 秒ほどの遅延は許容した設計となっているため、最終的な結果整合が取れる点、日常業務範囲のリクエスト量で問題になるような頻度で発生することはほとんど無い点から許容しています。

試練 4: 10,000 件のページネーション制限 (max_window_size)

OpenSearch にはデフォルトで 10,000 件までしか結果を取得できない(正確には offset が 1 万以上の検索結果表示ができない)というページネーション制限があります。max_window_size を調整することで変更可能ですが、OpenSearch Serverless では変更が不可になっています。
このため、検討の末、1 万件以上のページネーションは不可能としました。というのも、お客様が書類を探すとき、1 万件目までページネーションをして探すと言ったユースケースが調査の結果無かったためです。一方で、一気に最終ページに飛んで一番昔の書類を見るケースはありました。ここから、古い書類を見たいという需要はあると考え、書類を古い順に並べ替えることは出来るようにしています。

とはいえ、表示はできなくても、検索条件に当てはまる書類をページをまたいで全件送付や削除まで塞ぐことは避けたいです。
これは、Point In Time (PIT) API を使うことでクリアしました。PIT は、特定時点のインデックス状態をスナップショットとして保持し、そのスナップショットに対して複数回に分けて検索を実行できる機能です。
一括処理が投げられた際は、これによって PIT を作成した時の状態で書類を一覧でき、上限無くページネーションが出来るため、一括で対象書類の ID を取得して処理系に投げるという工夫をしています。

ちなみに、検索結果に出てくる count 数も max_window_size の影響を受けますが、検索時の param に track_total_hits という field を入れられ、これをオンにすると 1 万件以上でも件数はカウントできるようになっています。そのため、一覧で何件の書類があるかを確認できる機能は維持しています。

リプレイス結果と今後

OpenSearch 導入によって、お客様独自の項目での検索ができるようになりました。
ただし、お客様独自の項目を検索項目としてそのまま全て表示すると、検索条件数が多すぎて書類が探しづらくなってしまうため、使わない検索条件を非表示にし、お客様独自の項目を含んだ普段から使う項目のみ、検索フィールドを表示できるようにする機能と合わせてリリースしています。

bakuraku.jp

この記事では TODO となっている、書類一覧の表示項目カスタマイズも現在は対応しており、お客様の業務フローに合わせた書類検索・表示が現在は出来るようになっています。
非常に多くの要望を頂いていた機能だったため、リリースして喜びの声も頂き嬉しい限りです。

そして、この一連のリアーキテクチャにより、書類一覧の検索パフォーマンスも劇的に改善されました。

パフォーマンス改善のメトリクス

ここには出していませんが、中央値・平均値や、以前は最も遅いと数十秒かかっていた p100 含め、全体的なパフォーマンスが向上したことを確認しています。崖は何度みても良いので嬉しいですね。

ただ、このリリースによって同時に、書類を追加/編集した後検索結果に反映されるまでに 10 秒かかるという試練 1 による制約は常につきまとうことにはなってしまいました。
画面での見せ方の工夫は多数入れていますが、完璧にはまだ遠いとも思っています。

今後も様々な改善案を検討しています:

  • 新規作成した書類を楽観的に別途ページに表示
  • 最新の書類を早く表示したい箇所では SQL のみでの検索を混ぜるハイブリッド検索機構
  • 極論、Serverless をやめてマネージドクラスターにして特有の制約を回避する

今後もお客様の業務がなめらかに進められる UX を追求していきたいと考えています。

さいごに

社内での OpenSearch 事例として、検索を全面的に OpenSearch の filter query に寄せている事例はありませんでした。
ですが新卒として入社して早々に、このような大きなプロジェクトを信じて任せてもらい、無事完遂させ、お客様に価値を届けて行くことが出来て嬉しい限りです。
このタスクを信じて任せてくださったチーム・環境、そして要望をくださりプロダクトを利用してくださっているお客様に感謝しています。

今回の記事を読んで詳しい話が気になった方や、LayerX について知りたい方は、ぜひカジュアル面談でおしゃべりしましょう!

jobs.layerx.co.jp

ご覧頂きありがとうございました!




元の記事を確認する

関連記事