多くのメールクライアントは、検索を機能のひとつとして扱います。私たちは、レイテンシの予算として扱います。MailVault が存在する理由は、何十年分ものメールを自分のディスクに保管することにあります。ひと拍の間に検索できないアーカイブは、ゴミ捨て場です。

そこで 50,000 通の保管庫を作り、時間を測りました。最も遅いクエリで 14 ミリ秒でした。興味深いのは、そこに至るまでに何が必要だったかと、それを気づかないまま 30 倍遅くしてしまったリリースのことです。

数字

1 台のマシン、5 種類のクエリ、それぞれ 6 回の実行(修正を入れた作業用ビルドで 3 回、マージ後のコードで 3 回)。時間は、検索と、一覧が描画する行の組み立てを合わせたものです。

MailVault daemon · release build · Apple M4, 16 GB · warm cache
50,000 synthetic messages, avg 3,334 bytes, 3 folders

query                    matches   rows    time (6 runs)
invoice                  ~2,500    500     7.1 – 7.5 ms
budget meeting           246       246     13.4 – 14.1 ms
update 4999              15        15      13.5 – 14.0 ms
会議                    1,529     500     4.3 – 4.6 ms
last 7 days, no words    1,008     500     1.0 ms

index build (cold)       50,000 msgs   10.9 – 11.9 s
index on disk            224,968,704 bytes
result-row parses        0

コーパスは実際のメールではなく生成したものです。1 通あたりプレーンテキストと HTML でそれぞれ 200 語のフィラー語を入れ、さらに 10 個の単語を固定の割合で仕込んでいます(invoice 5%、budget 8%、meeting 6%、その中に日本語とアクセント付きの用語が 3 つ)。そのため、どのクエリにも答えの件数が既知です。ジェネレーターは決定的なので、再実行すれば同じ 50,000 ファイルが作られます。テスト中、マシンは他のビルドと共有されていました。つまり、実験室ではなく、負荷のかかったコンピューターでの結果です。

50,000 通のメッセージを保管した MailVault の保管庫で、invoice という語を検索した結果です。検索ボックスの下に、すべてのフォルダーで保存済みの一致約 2,500 件のうち新しい 500 件を表示していると示され、その下にメッセージ行の一覧が続きます。
アプリでの同じ種類の検索です。50,000 通のメッセージを保管した保管庫で「invoice」を検索しました。一覧には保存済みの一致約 2,500 件のうち新しい 500 件が表示され、そのことも明記されています。先頭の数行はこのウィンドウを共有するデモ用メールボックスのもので、保管庫のフォルダー名はベンチマークとは異なり Projects、Correspondence、Clients です。
50,000 通のメッセージに対するクエリごとの検索時間、ミリ秒単位 5 本のバー: 語句なしの直近 7 日間が 1.0 ms、日本語のクエリが 4.5 ms、invoice が 7.4 ms、budget meeting が 14.0 ms、update 4999 が 14.0 ms。どのバーも 16.7 ms の目盛りより手前で終わります。これは 60 Hz での画面リフレッシュ 1 回分です。 0 5 10 15 20 ミリ秒、検索と結果行の構築の合計 直近 7 日間、語句なし 1 ms 会議 (日本語) 4.5 ms invoice 7.4 ms budget meeting 14 ms update 4999 14 ms 60 Hz の画面リフレッシュ 1 回分: 16.7 ms
50,000 通のメッセージに対する 5 種類のクエリ、ミリ秒単位。破線は 60 Hz の画面リフレッシュ 1 回分です。

尺度を具体的にすると、60 Hz のディスプレイは 16.7 ms ごとに再描画され、上のどのクエリも 1 回の再描画の間に終わります。1993 年から変わらない Jakob Nielsen の応答時間の限界は、「瞬時」が 0.1 s、思考が途切れないのが 1 s、注意が離れるのが 10 s です。私たちの最も遅い応答は、最初の限界の 7 分の 1 です。下の目盛りは対数で、1 つ進むごとに 10 倍になり、その右端が、オフライン検索の最初のバージョンがいた場所です。

1 ミリ秒はどれくらいの長さか? 対数時間軸で見る検索時間 1 ミリ秒から 100 秒までの対数目盛り。MailVault の検索は 1 〜 14 ミリ秒の間にあり、16.7 ミリ秒の画面リフレッシュ 1 回分に近く、応答が瞬時に感じられる 100 ミリ秒の点をはるかに下回ります。従来のファイル単位の検索は、20,000 通のフォルダーで 88 秒と推定されます。 1 ms 10 ms 100 ms 1 s 10 s 100 s 50,000 通に対する MailVault の検索: 1 〜 14 ms 画面リフレッシュ 1 回分 60 Hz で 16.7 ms 瞬時に感じる 100 ms 未満 思考の流れ 1 s まで 注意が途切れる 10 s 以降 従来の検索 88 s (推定)
対数時間軸、1 ms から 100 s。応答時間の限界の出典: Jakob Nielsen。88 s の点は、ファイル単位だった元の検索を 20,000 通のフォルダーで実行した場合の推定コストです。

自分で確かめる

ベンチマークはソースツリー内の無視指定のテストなので、通常の実行が遅くなることはありません。一時ディレクトリにコーパスを作り、実際の MIME パーサーでインデックスし、上の行をすべて出力します。

cargo test -p mailvault-daemon --release \
  search_index_bench_50k_real_parser -- --ignored --nocapture

コーパスはクエリを実行する直前に新しいディレクトリへ書き込まれるため、ページキャッシュは温まっています。再起動後の最初の検索では、ディスクからより多く読み込みます。このケースは測定していないので、主張もしません。

検索エンジンではなく、素朴な SQL データベースを選んだ理由

要件は地味なものでした。インデックスは保管庫の中に置く必要があります。保管庫は外付けドライブや NAS のマウント先に移せるからです。オフラインで動く必要があります。捨てて作り直せる派生データである必要があります。そして、ユーザーに起動させたり、パッチを当てさせたり、説明したりするサービスを増やしてはいけません。

その条件を満たすのが、FTS5 全文検索モジュールを備えた SQLite です。ファイルは 1 つで、1 つのプロセスが開き、先行書き込みログモードと排他ロックで動かします。保管庫がネットワーク共有上にあることもあるため、この構成にしました。テキストは 2 つの仮想テーブルが保持します。

  • ラテン文字のテキスト用の trigram テーブル。 すべての単語が、重なり合う 3 文字の断片として保存されるため、 voic と入力すると invoiceが見つかります。ワイルドカード構文も、単語全体の一致という規則もありません。ダイアクリティカルマークは正規化されるため、 reunion と入力すると Réunionが見つかります。1 文字や 2 文字のクエリは trigram より短いので、件名と差出人に対する単純な部分文字列の一致にフォールバックします。
  • CJK 用の 2 つ目のテーブル。 日本語と中国語には区切りに使えるスペースがなく、単語も 2 文字であることが多くて trigram より短いため、1 文字ずつに分割し、それらをフレーズとして照合するトークナイザーを通します。これがなければ、 会議 を検索しても何も見つかりません。

どちらのテーブルも contentless です。検索用の構造だけを保持し、メールの 2 つ目のコピーは持ちません。50,000 通が 225 MB に収まる大きな理由のひとつです。

MailVault の設定、ストレージタブにある検索インデックスのカードです。50,000 / 50,000 件がインデックス済みで約 270 MB と表示され、メッセージ本文、添付ファイルのテキスト、画像内のテキストのスイッチが並んでいます。
設定、ストレージ、検索インデックスの構築が完了した状態です。50,000 通中 50,000 通がインデックス済みで、ディスク上の容量は約 270 MB です。アプリ内の別の実行なので、ベンチマークで計測した 224,968,704 バイトとは容量が異なります。

検索はメッセージを開かない

一覧には、ヒットごとに差出人、件名、日付、フォルダー、フラグが必要です。それらを得るために 500 個のメッセージファイルを解析すると、クエリそのものより高くつきます。そこでインデックスの行には、すでに組み立て済みの一覧の行を保存し、現在のフラグは maildir 形式がフラグを保持しているファイル名から読み取ります。ベンチマークは、結果を組み立てる間の MIME パーサーの呼び出し回数を数えており、答えはゼロです。

メッセージを開くのはクリックしたときだけで、そのときにインデックスが記録した Message-ID と照合されるため、サーバーが再発行した UID によって別のメールが表示されることはありません。

インデックスを正直に保つ

保管庫とずれたインデックスは、ないより悪いものです。「削除したらインデックスも更新する」というフックを長く連ねることはしません。1 つのリコンサイラーが、フォルダーの一覧(uid、ファイル名、サイズ、更新日時)とインデックスの内容を比較し、差分を修復します。書き込む側はそれを促すだけです。ファイルが壊れているか、新しいスキーマのものであれば、削除してメールから作り直し、復旧コードが削除するのは派生データであるインデックスの 4 つのファイルだけです。メッセージ、カストディ記録、アカウントには一切触れません。

最初のバージョンがしていたこと

最初のオフライン検索はファイルを読んでいました。検索のたびにフォルダーを一覧し、各メッセージを UID で引くたびにディレクトリを新たにスキャンし、解析し、すべての本文をプロセス境界越しにシリアライズして、JavaScript で絞り込んでいました。その一部を測ったところ、ディレクトリの再スキャンは 20,000 通のフォルダーで約 4.4 ms かかり、それがメッセージごとに 1 回実行されるので、そのフォルダーだけでおよそ 88 秒になる計算です。これがインデックスの存在する理由であり、ファイルを開かない検索は最適化ではなく設計上の制約である理由です。

出荷してしまった速度低下

この記事のための数値を準備している間に、ベンチマークが失敗しました。2.15.0 リリースを含むコードでは、「invoice」に 221 ms、「budget meeting」に 500 ms かかり、テスト自身の 200 ms のアサーションが 3 回の実行すべてで失敗しました。9 月 13 日には、同じクエリの検索ステップは 4〜7 ms と測定されていました。

原因は、良い機能を不注意に加えたことでした。リーダーで一致した語句を強調表示し、添付ファイルの中だけで見つかったヒットに印を付けるため、各結果行に 2 つの問いを持たせました。本文は一致するか、添付ファイルのテキストは一致するか、です。それぞれを、全文検索テーブルに 1 行ずつ問い合わせるサブクエリとして書いたのですが、このような相関サブクエリは、問い合わせる行ごとに再実行されます。実行のたびにその語のポスティングをたどり直すので、コストはクエリの語によって変わります。246 件ヒットした 2 語のフレーズ(500 ms)は、約 2,500 件ヒットした 1 語(221 ms)より遅くなりました。

修正は、形を 1 行変えるだけです。一致する行の集合をインデックスに 1 回だけ問い合わせ、その集合に含まれるかどうかを調べます。結果は同じで、既存の 18 件のクエリテストは変更なし、新しいガードテストが 1 件加わり、4 つの数値は 7.4、14.0、14.0、4.5 ms に下がりました。教訓は退屈なものです。200 ms のゲートは存在していましたが、50,000 通のメッセージを作るのに 20 秒かかるため、ignore が付いていました。誰も実行しないゲートはドキュメントにすぎないので、この修正には通常のテスト実行で走るガードを付けて出荷します。

修正前後の検索時間、ミリ秒単位 修正前: invoice が 220 ms、budget meeting が 510 ms、update 4999 が 76 ms、日本語が 37 ms。修正後: 7.4、14.0、14.0、4.5 ms。200 ms のゲートに印が付いています。 0 100 200 300 400 500 ミリ秒 (修正前は 3 回、修正後は 6 回の実行の典型値) invoice 220 ms 7.4 ms budget meeting 510 ms 14 ms update 4999 76 ms 14 ms 会議 (日本語) 37 ms 4.5 ms 200 ms のゲート 修正前修正後
50,000 通のメッセージに対する同じ 4 つのクエリを、クエリ行ごとのサブクエリを置き換える前後で比較したものです。破線は、ベンチマークがアサートする 200 ms のゲートです。

添付ファイルと Vision、Premium の側面

本文は無料です。添付ファイルのテキストは Premium のオプションで、同じインデックスに 1 つの列として加わるため、検索はメッセージとその中の文書を一緒にヒットさせます。

  • Office ファイル (Word、Excel、PowerPoint)は XML を zip にまとめたもので、すべてのプラットフォームで純粋な Rust で読み取られます。
  • PDF はテキストレイヤーを使います。macOS では PDFKit、それ以外では別の pdf-extract プロセスを使います。
  • 画像とスキャンした PDF は macOS ではデバイス上で Apple の Vision フレームワークを通し、1 文書あたり 50 ページを上限とします。小さな画像(10 KB 未満、または短辺が 128 ピクセル未満)は、読み取れる文字を含むには小さすぎるためスキップされます。Windows と Linux には OCR の工程はありません。

上限は意図的なものです。1 パートあたり 25 MB、アーカイブは展開後 50 MB までで、読み取れないファイルは記録された状態になり、再試行のループにはなりません。I/O エラーのような一時的な失敗は次回のスイープで再試行され、本当に未対応のファイルは、延々と試されないよう印が付けられます。この記事のために抽出時間は測っていません。抽出は添付ファイルごとに 1 回、バックグラウンドで実行され、そのコストはファイル次第です。

他のクライアントは自分たちの検索をどう説明しているか

他のクライアントは 1 つもベンチマークしておらず、どれも 50,000 通での検索時間を公表していません。したがって、これはストップウォッチではなく設計の比較です。Apple は 次のように述べています 。Spotlight の最初のインデックス作成には数時間、場合によっては数日かかることがあります。Microsoft は 次のように記しています 。クラシック版 Outlook の検索は Windows Search のインデックスに依存していること、それが完了するまで結果が不完全になりうること、インデックスされるのはキャッシュされたメールだけであることです。Thunderbird のトラッカーには、16 年前に起票された 報告 があり、大きなメールボックスでグローバルインデックスの作成が遅くなるという内容で、あるユーザーはデュアルコアのマシンで 36,000 通に数日かかったと書いています。これは個人の体験談で、しかも古いハードウェアの話なので、私たちの結果とは比較しません。

この結果でわからないこと

メールは人工的で短いものです。実際のメッセージはもっと長く、インデックスは本文のテキストとともに大きくなります。すべて 1 台の Mac でのウォームキャッシュでした。添付ファイルの検索は測っていません。サーバーにしかないメールは、そもそもインデックスに入っていません。MailVault はサーバーに問い合わせ、ローカルのヒットを先に表示し、その部分はプロバイダーに左右されるため、ここに数値はありません。Premium では、サーバーのメールボックスを 1 つではなく最大 5 つまで同時に検索できます。

5 万通のメール、ひと拍の間に。 MailVault はアーカイブのそばにプライベートな検索インデックスを持ちます。本文は無料、添付ファイルとデバイス上での画像テキストは Premium で利用できます。

MailVault を入手