検索を速くする仕組み¶
扱うこと: 本文インデックスの読み込みと照合の実装、正規表現の判定(lines/filter/scan)、並列検索・スレッドの使い回し・読んだ内容の使い回しによる速さの実測。扱わないこと: 検索の仕様そのもの(検索)、検索で照合するファイルを絞る 2-gram の索引(2-gram の索引)。先に読むページ: 検索。
本文インデックス(インデックスのファイルの形)の読み込みと照合、結果 1 件の作成は PowerShell から .NET を直接呼ぶ(tebunko/search/content_index_search.ps1 の searchContentIndex/searchContentIndexFiles。FileStream+StreamReader+[regex]。Select-String で 1 件ずつ結果を作ると件数が多いときに遅いため)。資産管理・EDR に注目されやすい実行時コンパイル(Add-Type/csc.exe)は使わない(画面の実装。実測: 50 万行で約 0.6 秒)。
1 行ずつ PowerShell で照合すると、行数に比例して時間がかかる(1 行あたり十数マイクロ秒)。このため本文インデックスのファイルを丸ごと読み(改行は書くときに LF にそろえてある)、正規表現を全文に 1 回かける(.NET の中で走る)。全文で一致しない本文インデックスのファイルは、そのまま飛ばす。 一致したときだけ、メタ情報の行から場所ごとの範囲(元のファイル名・場所の名前・中身の先頭と終わりの位置)の一覧を作り(readContentIndexPlaces)、一致の位置から元のファイル・場所・行番号を求める(ファイル名・場所の求め方)。全文にかけてよいかは getRegexScanMode が正規表現の書き方から判定する。
| 判定 | 正規表現の例 | 照合のしかた |
|---|---|---|
lines |
文字どおりのワード、見積.*確定、^顧客、\d+-\w+ |
一致は必ず 1 行に収まるため、全文での一致の位置から行を切り出す(1 行ずつの照合はしない)。メタ情報の行の中の一致は捨てる |
filter |
見積\s確定、[^,]+、a(?=b)(改行に一致しうる・行の外を見る) |
全文で一致しない本文インデックスのファイルは読み飛ばし、一致したものだけ場所ごとに 1 行ずつ照合する |
scan |
\Aabc、a(?!b)、(?i)abc(全文では 1 行と結果が変わりうる) |
場所ごとに 1 行ずつ照合する |
^$は全文用の正規表現にMultilineを付けて行の先頭・末尾に一致させる。判定は安全側に倒し、どの照合でも検索結果(行・行番号)は、元の TSV を 1 行ずつ照合したときと同じになる(content_index_search.Tests.ps1で改行の種類・照合のしかたごとに突き合わせている)。- 全文への照合が時間切れになった本文インデックスのファイルは、1 行ずつ照合し直す。
- 文字どおりに探すときは、照合の前に gram 索引(gram 索引)で、検索語の 2-gram を含まない本文インデックスのファイルを外す。索引が使えない・古い・壊れているときは、そのファイルを必ず照合するので、結果は変わらない。
- ファイルの種類(
newFileKindFilterから作るnewFileFilter)・図形とコメント(newPlaceExclude)の条件は、場所ごとに元のファイル名・場所の名前で判定し、当てはまらない場所の一致は結果に入れない(1 行ずつ照合するときは、その場所を照合しない)。
さらに、次の 4 つで待ち時間を減らしている。
flowchart TD
files[("本文インデックスの<br>ファイル群")] --> split["splitContentIndexTasks<br>合計およそ 16MB ずつの作業に分ける"]
split --> many{"作業が 2 つ以上?"}
many -- はい --> pool["WorkerPool<br>(コア数 − 1、1〜4)で並行して照合"]
many -- いいえ --> single["1 スレッドで照合"]
pool --> merge["結果を作業の順に取り込む<br>(進み具合・上限・中止は 1 スレッドと同じ)"]
single --> merge
cache[("newTsvTextCache<br>読んだ内容と場所の一覧(上限約 128MB)")] -. 使い回し .-> split
merge --> trim["trimTsvTextCache<br>上限の 9 割を超えたら古いものから追い出す"]
| しくみ | 内容 |
|---|---|
| 並列検索 | 本文インデックスのファイルを、大きさの合計がおよそ 16MB になるまでまとめて 1 つの作業にし(splitContentIndexTasks)、作業が 2 つ以上なら照合のプール(WorkerPool。コア数 − 1、1〜4)で並行して照合する。各スレッドには照合に要る関数(searchContentIndexFiles・readContentIndexPlaces など)と値だけを読み込む。結果は作業の順に取り込み、上限・中止・進み具合(1 つの作業を照合するたびに通知。件数は本文インデックスのファイルの数)の扱いは 1 スレッドのときと同じ。中止は要求(Stop)を各スレッドにそのまま渡し、本文インデックスのファイル 1 件ごとに見る。止めたあとは、残った作業を受け取って捨てる(スレッドを止めずに、プールを次の検索で使える)。時間切れ・打ち切り・例外のときだけ、残った作業を Cancel で捨てる |
| スレッドの使い回し | 画面は、検索の司令のスレッド(SearchService)を画面を開いている間 1 つだけ動かす。lib.ps1 の読み込みと照合のプールの用意(newContentIndexWorkerPool)は、司令のスレッドを始めたときに 1 回だけ行い、検索のたびに行わない。検索 1 回は要求(newSearchRequest)として渡し、invokeSearchRequest が実行する。新しい要求を渡すと、前の要求の Stop を立てて取り消す(寿命) |
| 列挙 | 本文インデックスのファイルの列挙(getContentIndexFiles → findContentIndexFiles)は、.NET(DirectoryInfo.GetFiles)で content_index.*.tsv を 1 回で集め、更新日時・サイズも列挙のときに得る |
| 読む大きさの上限 | 本文インデックスのファイルは丸ごと読むため、大きさが contentIndexSearchMaxBytes(64MB)を超えるファイルは読まずに飛ばす(検索の結果の Skipped に入り、画面に件数が出る)。並列の検索は最大 4 スレッドなので、同時に持つのは最大でこの 4 倍(256MB)。正しく作ったファイルは 4MB(contentIndexFileMaxBytes)が目安で、1 つの元のファイルで超えるときだけ大きくなる |
| 読んだ内容の使い回し | 画面は newTsvTextCache を 1 つ持ち、検索で読んだ本文インデックスのファイルの内容と場所の一覧を残す(上限 6,400 万文字=約 128MB)。次の検索では、更新日時・サイズが列挙したときと同じ本文インデックスのファイルはファイルを開かない。書き直した本文インデックスのファイルは更新日時・サイズが変わるため読み直す。選択行のプレビュー(readContentIndexLines)も同じ内容を使う。キャッシュに無いときは、画面が最後に読んだ 1 つのファイルの控え(場所の一覧と、場所ごとの 200 行ごとの文字の位置。更新日時・サイズが同じ間だけ使う)から、目当ての行の手前の位置から必要な行までだけを読む(readContentIndexRange)。同じファイルの行を↑↓で移るたびに、ファイルの全体を読み直さない。ただし、初めて開くファイル(前に読んだのが別のファイル・控えが無い・更新日時かサイズが変わった)の最初の読みは、ファイルの全体を読む。検索 1 回が終わるたびに、上限の 9 割を超えていれば、その検索で使わなかった内容を古いものから追い出す(trimTsvTextCache。GC とメモリ) |
本文インデックスにする理由: 検索の時間は、読むファイルの数でほぼ決まる。Microsoft Defender は、ファイルを初めて開くたびに検査し(実測で 1 ファイル約 11 ミリ秒)、検査済みとして覚えておけるのはおよそ 2 万ファイルまで。元のファイルの場所ごとの TSV を読む方式では、TSV が数万件になると初回の検索に数分〜10 分以上かかった。
実測(Windows PowerShell 5.1、4 コア・物理メモリ 8GB、Microsoft Defender は有効。画面と同じく、列挙から検索の終わりまで):
| インデックス | 検索 | 場所ごとの TSV | 本文インデックス |
|---|---|---|---|
| 7,000 ファイル規模(TSV 6,412 件 → 本文インデックスのファイル 20 件・46MB) | 初回 | 30.0 秒 | 2.1 秒 |
| 同上 | 2 回目以降 | 1.3 秒 | 0.29 秒 |
| TSV 16 万件・1GB(→ 本文インデックスのファイル 502 件・1.1GB) | 初回 | 10 分超 | 38 秒 |
| 同上 | 2 回目以降 | – | 16〜18 秒 |
- 1GB の 2 回目以降は、読んだ内容の使い回しの上限(約 128MB)を超え、物理メモリ 8GB では OS のファイルキャッシュにも収まりきらないため、ディスクの読み込みの速さで決まる。
- 同じ形のデータを使った
pack の作成・検索の速さとリソースの推移は、GitHub Actions のperf.yml(手動で起動)でも測れる。計測スクリプトはtools/measure_perf.ps1(性能とリソースの計測のしかた)。 - 検索の速さが落ちていないことは、回帰テスト(
tests/tools/perf_search.Tests.ps1)が語ごとの中央値を上限と比べて確かめる。選択行のプレビューの読み込みと残るメモリはtests/tools/perf_preview.Tests.ps1が確かめる。PR にラベルperf-checkを付けたときと main への push でperf-check.ymlが流す(速さの回帰テストと上限の決め方)。