STGYの全文検索機能
本記事では、STGYの投稿とユーザを対象とする全文検索機能について説明する。STGYでは、PostgreSQLに全文検索用のインデックスを追加するのではなく、SQLiteのFTS5を使った独立サービスであるTTTS(time-tiered text search)を用いる。検索インデックスを時系列で複数のファイルに分割し、新しいファイルから順に検索して、必要な件数が集まった時点で処理を打ち切る構成である。
この機能では、関連度の高い文書を探すことよりも、新しい文書を一定時間以内に取得できることを重視している。また、検索インデックスはPostgreSQLのデータから再構築できる派生データと位置づけ、更新処理を非同期化して、検索サービスの障害が投稿やユーザの更新処理を直接止めないようにしている。
全文検索を別サービスにする理由
投稿本文やユーザの自己紹介を単純に検索するだけなら、PostgreSQLで以下のようなクエリを実行することもできる。
SELECT id FROM post_details WHERE content ILIKE '%検索語%';
しかし、この方法では、検索語に一致する可能性のある本文を広範囲に読まなければならない。全文検索用のインデックスを追加すれば検索を高速化できるが、投稿やユーザの更新のたびに大きなインデックスを更新する必要がある。また、頻出語に一致する文書を新しい順で取得する処理では、検索結果の候補が大量になりやすい。全文検索用インデックスで候補を列挙してから、別のインデックスで新しい順にソートする構成では、データ量が増えた時に処理量を予測しにくい。
STGYで必要なのは、一般的なWeb検索エンジンのような高度な関連度ランキングではない。投稿一覧、ユーザ一覧、外部公開サイトの検索欄に文字列を入力し、それを含む文書を一定件数取得できればよい。そこで、TTTSでは候補を新しい文書から取得できるように検索順序をインデックスの内部IDへ組み込み、検索対象を時系列の範囲ごとに分割することにした。
全文検索を別サービスにすると、以下の利点もある。
- 検索処理が重くなっても、PostgreSQLの通常クエリを直接圧迫しにくい。
- 検索インデックスの更新失敗を投稿やユーザの更新処理から分離できる。
- 検索インデックスを破棄して、PostgreSQLから再構築できる。
- 投稿とユーザでインデックスファイルや設定を独立させられる。
- 将来、検索サービスだけを別ホストへ移動しやすい。
一方で、PostgreSQLへの書き込みと検索インデックスへの反映は同一トランザクションではなくなる。検索結果は一時的に古い状態になり得る。この性質を受け入れた上で、更新要求を失わないためのキューと、インデックスを再構築するための管理機能を用意している。
全体構成
全文検索には、PostgreSQL、STGYのバックエンドワーカー、TTTS、SQLite、Redisが関わる。投稿を作成または更新する場合の大まかな流れは以下の通りである。
投稿をPostgreSQLへ保存 ↓ search_indexing_tasksへインデックス更新要求を追加 ↓ searchIndexWorkerが更新要求を取得 ↓ TTTSの/postsへADDまたはREMOVEを送信 ↓ TTTS内部の永続キューへ登録 ↓ SQLite FTS5の該当シャードを更新してCOMMIT
検索時の流れは以下の通りである。
フロントエンド ↓ STGYバックエンドの/posts/searchまたは/users/search ↓ Redisの検索結果キャッシュを確認 ↓ TTTSの/posts/searchまたは/users/search ↓ 新しいSQLiteシャードから順にFTS5検索 ↓ 実検索トークン列・フレーズ単位列と文書ID配列を返す ↓ PostgreSQLから投稿またはユーザの表示用データを取得 ↓ 必要なら フレーズ単位列ごとのRedis KWICキャッシュを確認 ↓ cache missした表示中IDだけ PostgreSQLから原文を取得してKWIC ASTを生成
TTTSは、投稿用の posts とユーザ用の users を別リソースとして起動する。同じHTTPサーバの中にあるが、インデックスファイル、タスクキュー、設定、検索処理は独立している。ファイル名にもリソース名が接頭する。
posts-1700000000.db posts-common.db users-1700000000.db users-common.db
*-common.db はタスクキューを格納するSQLiteデータベースであり、時刻を含むファイルはFTS5インデックスのシャードである。
検索対象となる文書
投稿の検索対象は、Markdownのソースそのものではなく、Markdownから表示用記法を取り除いて作ったプレーンテキストである。見出しと本文を別フィールドとして扱わず、一つの本文としてトークナイズする。投稿のタイトルがH1見出しとして本文に含まれていれば、他の本文と同じように検索対象になる。
投稿のトークナイズに使うロケールは、以下の順で決定する。
投稿に明示されたlocale ↓ nullなら 投稿所有者のlocale ↓ nullなら STGY_DEFAULT_LOCALE ↓ それもなければ en
投稿を新規作成した場合、更新した場合、検索インデックス全体を再構築した場合の全てで、この優先順位を使う。DB上の投稿のlocaleはnullになり得るが、TTTSへ渡す実効ロケールは必ず決定される。
ユーザの検索対象は、ニックネームと自己紹介を改行で連結し、自己紹介のMarkdownをプレーンテキストへ変換したものである。
ニックネーム 自己紹介のプレーンテキスト
ユーザ文書には、そのユーザのlocaleを使う。ユーザのlocaleは必須属性なので、通常はフォールバックを必要としない。
文書のタイムスタンプには、投稿またはユーザのSnowflake IDから取り出した作成日時を秒単位で使う。投稿の公開日時や更新日時ではない。したがって、投稿を編集しても別の時系列シャードへ移動せず、TTTSの候補順も作成時刻を基準としたままである。
PostgreSQL側の更新キュー
STGYのバックエンドは、投稿やユーザの更新時にTTTSへ直接同期通信を行わない。PostgreSQLの search_indexing_tasks テーブルへ、インデックス更新要求を登録する。
CREATE TABLE search_indexing_tasks ( id BIGINT PRIMARY KEY, name_prefix VARCHAR(50) NOT NULL, doc_id VARCHAR(100) NOT NULL, doc_timestamp BIGINT NOT NULL, body_text TEXT, locale VARCHAR(10) ); CREATE INDEX idx_search_indexing_tasks_name_prefix_id ON search_indexing_tasks(name_prefix, id);
posts用とusers用のワーカーはname_prefixを固定してID順にタスクを取得するため、(name_prefix, id)の複合インデックスを使う。別prefixのバックログを読み飛ばさずに、対象prefixの古いタスクから定数件を取得できる。
name_prefix は posts または users である。body_text がnullなら削除要求であり、nullでなければ追加または更新要求である。文書IDとタイムスタンプがあれば、削除対象のシャードを特定できる。
searchIndexWorkerは、postsとusersについて独立した処理ループを動かす。それぞれ、古いタスクから最大100件を取得し、TTTSへ順に送る。TTTSが要求を内部キューへ受理した後で、PostgreSQL側のタスクを削除する。
search_indexing_tasks ↓ 最大100件取得 TTTSへ送信 ↓ HTTP成功 処理済みタスクをPostgreSQLから削除
TTTSへの送信後、PostgreSQL側の削除前にワーカーが停止した場合、同じ要求が再送されることになる。しかし、追加は同じ文書IDへの置換であり、削除は存在しなければ何もしないので、重複実行しても最終状態は変わらない。このように、厳密な一度だけの実行を目指すのではなく、少なくとも一度は実行し、重複を許容する構成にしている。
ワーカーは各タスクの後に、デフォルトで500ミリ秒待つ。また、処理失敗後や一巡後にも待機時間を置く。インデックス更新を最短時間で消化することよりも、通常のバックエンド処理やTTTSへ過剰な負荷をかけないことを優先している。
TTTS内部の永続キュー
TTTSへ届いた要求も、すぐにFTS5へ直接反映するわけではない。リソースごとのcommonデータベースに、以下のキューを持つ。
document_tasks: まだ処理を開始していない文書追加・削除要求batch_tasks: FTS5のトランザクションへ反映中またはCOMMIT待ちの文書要求management_tasks: flush、optimize、reconstruct、reserve、シャード削除などの管理要求
文書タスクを処理する際には、最初に document_tasks から batch_tasks へ移す。この移動はcommonデータベース内の一つのトランザクションで行う。
document_tasks ↓ BEGIN IMMEDIATE batch_tasksへINSERT ↓ document_tasksからDELETE ↓ COMMIT batch_tasks
その後、対象シャードのFTS5を更新する。batch_tasks の行は、対応するシャードのトランザクションがCOMMITされるまでは削除しない。
batch_tasksに保持 ↓ FTS5へADDまたはREMOVE ↓ SQLiteインデックスをCOMMIT ↓ batch_tasksから削除
この順番は、障害復旧にとって重要である。FTS5へ未COMMITの更新を行った後でTTTSが停止すると、SQLite側の更新はロールバックされる。しかし、batch_tasks は残っている。TTTSは起動時に残存するタスクを読み出して再実行し、全シャードをCOMMITする。
逆に、FTS5のCOMMIT後、batch_tasks の削除前に停止した場合には、次回起動時に同じ処理が再実行される。追加と削除は冪等なので、更新の欠落より重複実行を選ぶ方が安全である。
管理タスクは文書タスクとは別のキューで処理される。管理タスクが存在する場合は文書タスクより先に処理される。メンテナンスモード中は文書タスクの処理を止めるが、管理タスクは処理できる。
インデックスの時系列シャーディング
一つの巨大なFTS5データベースを使うのではなく、文書の作成時刻を一定期間のバケットに分け、それぞれを独立したSQLiteファイルに格納する。
シャードの開始時刻は以下の式で求める。
floor(timestamp / bucketDurationSeconds) * bucketDurationSeconds
バケット期間は、投稿用とユーザ用に別々の環境変数で設定できる。
STGY_SEARCH_POSTS_BUCKET_DURATION STGY_SEARCH_USERS_BUCKET_DURATION
現在リポジトリに含まれるenv.dev.txt、env.vps.txt、env.native.txt、env.hybrid.txtでは、両方とも50000000秒に設定されている。約579日で一つのシャードが作られる設定である。コード上のデフォルト値は投稿とユーザで異なるが、通常の配布設定では環境変数の値が優先される。
検索時には、シャードを開始時刻の新しい順に走査する。各シャードから必要な件数だけ取得し、offset + limit 件が集まったら、それより古いシャードは検索しない。
最新シャードを検索 ↓ 件数が不足 一世代前を検索 ↓ 必要数に到達 終了
SNSでは、新しい投稿ほど参照されやすく、検索結果でも新しいものが先に求められることが多い。古い文書まで必ず検索する構成ではなく、必要な時だけ古いシャードへ進むことで、データ量が増えた後も一般的な検索の処理量を抑えられる。
シャードをファイル単位に分けると、破損時の復旧範囲も限定できる。詳細付きのシャード一覧APIでは、ファイルを開いてテーブルやFTS5の統計を取得できるかを確認し、開けないものを不健全なシャードとして報告する。管理ユーティリティは、不健全なシャードだけをPostgreSQLから再構築することもできる。
FTS5のデータ構造
各シャードには、主に以下のテーブルがある。
CREATE TABLE id_tuples ( internal_id INTEGER PRIMARY KEY, external_id TEXT UNIQUE, labels_json TEXT NOT NULL DEFAULT '[]', numeric_value REAL ); CREATE TABLE extra_attrs ( external_id TEXT PRIMARY KEY, attrs TEXT ); CREATE TABLE fts_meta ( k TEXT PRIMARY KEY, v INTEGER ); CREATE VIRTUAL TABLE docs USING fts5( tokens, labels, tokenize = "unicode61 categories 'L* N* Co M* P* S*' remove_diacritics 0 tokenchars ' '", detail = 'none' );
recordPositions、recordContents、autoPhraseCheck は環境変数で設定できる。
STGY_SEARCH_RECORD_POSITIONS STGY_SEARCH_RECORD_CONTENTS STGY_SEARCH_AUTO_PHRASE_CHECK
現在のenv.dev.txt、env.vps.txt、env.native.txt、env.hybrid.txtでは、recordPositions=false、recordContents=true、autoPhraseCheck=false に設定している。そのため上記のスキーマ例では detail='none' となり、FTS5のposting listには文書IDだけを保持して列番号や単語位置を記録しない。recordPositions=true では detail='full' となり単語位置を記録する。recordContents=false ではFTS5をcontentless構成(content=''、contentless_delete=1)にする。本文の tokens と探索空間を限定する labels は従来どおり別列に保持するが、recordPositions=false の検索では列指定を使わない。
labelはFTS5へ登録する際に U+E000 + "L" + label というsynthetic tokenへ変換して labels 列へ保存する。U+E000はTTTS内部専用の予約文字であり、外部から与えられる本文・検索query・labelにU+E000が含まれる場合は、いずれもtokenizeやlabel正規化より前にU+0020 SPACEへ置換する。このため本文由来termとsynthetic label termは衝突しない。検索時は tokens: や labels: のcolumn filterを使わず、synthetic label条件と本文条件をtable-wideな一つの MATCH 式へ入れる。FTS5は同じtable内のposting listをAND交差できるため、recordPositions=false ではcolumn情報を保存する必要がなく detail='none' を使える。一方、recordContents=true では保存内容の tokens / labels 列は分離されたままなので、疑似フレーズ確認や本文取得では tokens 列だけを直接扱える。
このsynthetic label形式は従来のraw labelを labels 列へ保存する形式との後方互換性を持たない。TTTSをこの形式へ更新する際は、既存検索インデックスをリセットし、PostgreSQLから再構築する。
recordPositions と recordContents はシャード作成時に fts_meta へ記録される。既存シャードを開く際はenvの現在値ではなく fts_meta の値を使うため、運用中にenvを変更しても既存シャードの構造や検索semanticsは変わらない。設定変更を既存データにも適用する場合は、検索インデックスをリセットして新しい設定で再生成する必要がある。一方、autoPhraseCheck は検索時だけに使う設定であり、インデックス形式を変更しないため fts_meta には保存しない。TTTSを新しいenvで再起動すれば既存シャードにも適用され、インデックスの再生成は不要である。
外部IDは external_id、FTS5のrowidは小さな整数の internal_id とする。新しい文書ほど小さいinternal_idを割り当て、ORDER BY docs.rowid ASC で新しい文書から取得する。
検索では docs CROSS JOIN id_tuples としてFTS5を外側ループに固定し、候補ごとに id_tuples をINTEGER PRIMARY KEYで参照する。FTS5式ではsynthetic label条件を本文条件より先に置き、column filterを使わないtable-wide MATCHの中でlabelと本文のposting listを交差させる。labelはFTS5のcase-insensitiveな一致をそのまま採用し、labels_json による再確認は行わない。numeric_value は同じ候補に対するpredicateとして評価し、検索用B-tree indexは作らない。
EXPLAIN QUERY PLAN のテストで、FTS5走査→INTEGER PRIMARY KEY検索の順序と、USE TEMP B-TREE が発生しないことを確認する。これにより、属性条件を追加しても新しい候補からLIMITまで走査して早期終了する構造を維持する。
トークナイズと正規化
TTTSは文書をそのままFTS5へ渡さず、Node.js側でトークナイズしてから、各トークンをLF(改行)区切りにして登録する。原文の改行を保存するためではなく、TTTSが決めた一つのトークンをFTS5でも一つのtermとして扱わせるための内部表現である。
最初に以下の正規化を行う。
- UnicodeをNFKCで正規化する。
- 英字を小文字にする。
- 制御文字と不可視の書式文字を空白に置換する。
- TTTS内部予約文字のU+E000をU+0020 SPACEへ置換する。
- トークン内のダイアクリティカルマークを除去する。
例えば、大文字小文字や全角半角の違いを吸収し、Café は概ね cafe として登録される。
日本語でKuromojiが有効な場合は、KuromojiのIPADIC辞書で形態素解析する。それ以外は、JavaScript標準の Intl.Segmenter を単語単位で使う。Docker版TTTSでは、STGY_SEARCH_ENABLE_KUROMOJI=true が設定されている。
通常の単語分割では記号が落ちる場合があるため、以下のような技術用語は前処理で抜き出し、一つのトークンとして保持する。
C++ → c++ C# → c# .NET → .net node.js → node.js
一方で、一般的な記号区切りは通常の単語分割に従う。
check-out → check, out 24/7 → 24, 7
FTS5側のunicode61設定では、句読点と記号に加えてU+0020 SPACEも tokenchars へ追加する。本文トークンとsynthetic labelに現れる印字文字とSPACEはterm内部に保持され、TTTSが内部表現として挿入したLFでtermが分かれる。これにより、アプリ側で作った c++ やSPACEを含む一つのlabelをFTS5が再分割しない。synthetic labelの先頭U+E000はPrivate Use文字(Co)なので、このunicode61設定ではtermの一部として保持される。FTS5の検索式において特別な意味を持つ文字を含むtermは、検索式を作る際に引用符で囲む。
検索queryは空白区切りの入力単位を解釈する前にU+E000をSPACEへ置換する。このため、例えば foo<U+E000>bar は foo bar と同じ二つの入力単位として扱われ、positionsありの場合にも誤って一つのphraseにはならない。
文書は、指定された実効ロケールで本文全体をトークナイズする。行ごとの言語判定は行わない。検索クエリでは、指定ロケールを優先候補として、文字種による簡単なロケール判定を行う。
- ひらがなまたはカタカナがあれば日本語
- ハングルがあれば韓国語
- 漢字があり、優先ロケールが中国語なら中国語
- それ以外の漢字は日本語
- 判定材料がなければ指定ロケール
これは高度な言語識別器ではない。投稿の言語は投稿または所有者のlocaleを信頼し、クエリ側だけで日本語・中国語・韓国語の明白な文字種を補正する仕組みである。
一つの文書から登録するトークン数は最大50000個である。検索クエリで使うトークン数は最大8個である。巨大な本文や極端に複雑な検索式で、メモリやCPUを過剰に消費しないための制限である。
トークナイザの結果はHTTP APIで確認できる。
curl --get 'http://localhost:3200/posts/tokenize' \ --data-urlencode 'text=私はC++言語が好きです' \ --data-urlencode 'locale=ja'
Kuromojiが有効な環境では、概ね以下の結果になる。
["私","は","c++","言語","が","好き","です"]
検索クエリの意味
空白で区切られた各入力単位を個別にトークナイズする。recordPositions=true のシャードでは、一つの入力単位が複数トークンへ分割された場合、ダブルクォートで囲まれていなくても、それらを一つの位置付きフレーズとして検索する。例えば インストールや設定作業 が内部的に インストール、や、設定、作業 へ分割された場合でも、それらの連続を要求する。一方、最初から空白で分けて入力した単位同士はAND条件のままである。
recordPositions=false かつ recordContents=true で autoPhraseCheck=true の場合は、漢字・ひらがな・カタカナ・ハングルのいずれかを含む引用符なしの一入力単位が複数トークンへ分割されたときにも、自動的にフレーズとして扱う。例えば 脚本家 が 脚本、家 へ分割された場合、FTS5では 脚本 AND 家 で候補を絞り、その後、保存済みcanonical token列に 脚本\n家 が連続して現れることを確認する。CJK文字を含まない入力単位にはこの自動判定を適用しない。デフォルトの autoPhraseCheck=false では従来どおりAND条件のままである。recordContents=false では連続性を確認できないため、autoPhraseCheck=true でもAND条件へ退化する。
ダブルクォートは明示的なフレーズ指定であり、recordPositions=false かつ recordContents=true のシャードでは、複数トークンの引用フレーズをFTS5のAND条件で候補絞り込みした後、保存済み docs.tokens のcanonical token列で連続一致を確認する。synthetic labelは別の labels 列に保存されるため、疑似フレーズ確認には一切混入しない。引用フレーズは autoPhraseCheck の値に関係なく従来どおり処理する。
OR、NOT、ワイルドカード、曖昧検索、関連語展開はUI上の検索構文として提供しない。検索クエリのトークン数は最大8個である。
GET /{resource}/search と GET /{resource}/search-fetch は、どちらもトップレベルに tokens、phrases、result を持つオブジェクトを返す。tokens はTTTSが実際にFTS5検索式を組み立てるために使った正規化済みの最小本文検索トークン列であり、検索トークン数上限を適用した後、FTS5用の引用符付加やAND/phrase構文へのserializationを行う前の値である。phrases はKWICなど後段表示処理がそのまま使える完全な検索単位列である。一トークンの検索語も一要素として必ず含み、複数トークンが実際に連続フレーズとして拘束される場合は、それらを一つの文字列として含む。例えばpositionsなし・autoPhraseCheck=false で インストールや設定作業 がAND分解される場合は tokens = ["インストール", "や", "設定", "作業"] かつ phrases も同じ4要素になる。一方、positionsありでは同じ入力が連続フレーズとして検索され、tokens = ["インストール", "や", "設定", "作業"]、phrases = ["インストールや設定作業"] となる。また、positionsなしでも autoPhraseCheck=true で 脚本家 が 脚本、家 に分かれ、canonical token列による連続一致を要求する場合は、tokens = ["脚本", "家"]、phrases = ["脚本家"] となる。順序と重複は保持し、label条件と数値条件は含めない。search の result は文書ID配列、search-fetch の result はTTTS内の文書配列である。検索結果が0件でも tokens と phrases は返す。
labelsとnumeric_valueによる探索空間の限定
各文書には、0個以上のopaqueな labels とnullableな汎用数値 numeric_value を持たせられる。labelは印字文字とU+0020 SPACEを含む任意文字列を一つのtermとして扱い、FTS5ではcase-insensitiveに一致する。外部入力のU+E000は内部予約文字なのでU+0020 SPACEへ正規化し、その正規化後labelを labels_json に保持する。FTS5の labels 列へは、さらに U+E000 + "L" を先頭に付けたsynthetic label tokenとして保存する。検索ではcolumn filterを使わず、このsynthetic label termを本文termと同じtable-wide MATCHへ入れる。複数labelはAND条件で、FTS5式ではlabel条件を本文条件より先に置く。数値条件は eq、gt、gte、lt、lte を使う。TTTSはそれらのアプリケーション上の意味を解釈しない。labels_json による検索時の再判定は行わない。
STGYの投稿では owner:<userId> をlabel、外部公開日時のepoch millisecondを numericValue とする。非公開はNULL、予約公開は未来日時である。ログイン検索では owner:<userId> / owner:me をlabel条件、status:published を numeric_value <= now へ変換する。
labelはFTS5のposting-list intersectionへ直接入るため、例えば特定ユーザの投稿検索ではSNS全体の本文ヒットを後段でowner判定する必要がない。数値条件はFTS候補の後段で評価するため、K件を得るまでH件の候補を見る場合、属性確認の追加コストは概ね O(H log N) である。数値条件の通過率をpとすれば概ね H ≈ K/p、最悪時は本文+label条件に一致する候補M件まで見る。
外部公開サイトからの匿名検索
/sites/[id] の検索はSSR中にTTTSを呼ばず、hydrate後のClient Componentが credentials: "omit" で /posts/search を呼ぶ。匿名検索では ownedBy を必須とし、バックエンドが owner:<ownedBy> と numeric_value <= now を強制する。TTTSの候補取得後もPostgreSQLで owned_by と published_at <= now を再確認する。
匿名の検索系APIでは、全query parameterをcanonicalizeしたUTF-8文字列のSHA-1を X-STGY-QueryHash ヘッダで送る。これは秘密情報ではなく、URLをそのまま反復する単純なクローラを安価に落とすためのフィルタである。カスタムヘッダを使うため他originのJavaScriptからはCORS preflightが必要になり、許可originはSTGY frontendに限定される。
本番Caddyでは /backend/posts/search と /backend/posts/kwic-pub が同じ検索系のIP単位制限(60回/1時間)を共有し、STGY全体にも一般レート制限がある。匿名検索は固定ダミーユーザの DailyTimerThrottleService("db") 日次処理時間枠も共有する。またSTGY全ページは frame-ancestors 'none' と X-Frame-Options: DENY でframe埋め込みを禁止する。
検索処理と結果の順序
TTTSの検索APIは /posts/search と /users/search を持ち、{tokens, phrases, result} を返す。result のTTTS候補IDは作成時刻の新しい順である。STGYバックエンドはTTTSが返した tokens と phrases の両方を保持し、PostgreSQLから投稿・ユーザの表示データを取得した後も同じトップレベル構造でfrontendへ返す。通常のログイン検索では、画面で必要な offset + limit 件まで候補IDを取得する。
外部公開投稿検索では、TTTSから上限付き候補集合を取得し、PostgreSQLでownerと公開時刻を再確認して公開日時の新しい順にページングする。現在の /sites/[id] 検索結果ではOldest切り替えを表示しない。
TTTSは候補IDを返す副次インデックスであり、投稿・ユーザの表示内容と公開可否の正本はPostgreSQLである。
Redisによる検索結果キャッシュ
STGYバックエンドは投稿検索とユーザ検索について、TTTSが返した tokens と phrases、候補ID配列を一緒にRedisへキャッシュする。投稿検索のキーにはquery、locale、owner、公開条件、匿名/認証済みの区別を含める。
通常検索では必要件数に応じて候補キャッシュを拡張する。候補配列を拡張するときは既存キーの残りTTLを引き継ぎ、初回作成時からの失効時刻を延長しない。外部公開検索では上限付き候補集合をキャッシュし、ページ移動やKWIC/Rich/Plain切り替えで同じTTTS検索を繰り返さない。デフォルトTTLは180秒である。
検索APIでは offset + limit に上限を設け、ログイン検索はユーザ単位、匿名検索は共有ダミーユーザ単位のDB処理時間制限を適用する。
KWIC
検索結果のKWIC(Keyword In Context)はTTTSでは生成しない。frontendはrawな検索文字列を再解釈せず、検索レスポンスの phrases だけをKWICのkeywordとして使う。phrases は一トークンの検索語も全て含む完全なリストなので、KWIC側で tokens から検索単位を再構成する必要はない。例えばpositionsなしで「インストールや設定作業」が「インストール」「や」「設定」「作業」のAND検索になれば、その4要素をそれぞれanchorにできる。一方、positionsありで同じ入力が連続フレーズとして検索される場合は、phrases = ["インストールや設定作業"] を一つのKWIC単位として使う。
STGYバックエンドは、resource種別と順序を保持した phrases 配列のhashをキーにしたRedis HASHへKWICをキャッシュする。HASHのfieldは文書IDであり、要求されたID群は HMGET で一括取得する。cache missしたIDだけをPostgreSQLから取得して packages/markdown でKWIC ASTを生成し、生成分を一度の HSET で追加する。検索クエリに含まれるlabel、数値条件、owner、公開条件、offset、limitはKWICそのものを変えないため、このKWICキャッシュキーには含めない。同じ順序の phrases と同じ文書なら、異なる検索条件からも再利用できる。
KWIC HASHのTTLには検索キャッシュと同じデフォルト180秒を使うが、寿命は検索結果キャッシュとは独立している。TTLはそのkeyword HASHが最初に作られた時だけ設定し、後から別文書のKWICを HSET しても延長しない。/posts/kwic-pub はcache hitしたIDについてPostgreSQLで現在の公開可否だけを一括確認し、cache missしたIDは従来どおり公開条件付きで原文を取得するため、匿名公開条件をキャッシュで迂回しない。
バックエンドには以下のエンドポイントがある。
GET /posts/kwic # ログイン必須 GET /users/kwic # ログイン必須 GET /posts/kwic-pub # 匿名可、公開済み投稿のみ
/posts/kwic-pub は X-STGY-QueryHash と匿名スロットリングの対象で、published_at <= now をPostgreSQLで確認する。
投稿ではMarkdownのタイトルを全文表示し、本文は検索語の前後40 pseudo tokensを基本範囲として最大4セグメント抽出する。一つの検索語だけで全セグメントを占有しないよう、各検索語をanchorとして使える数を ceil(maxSegments / keywordCount) に制限し、重なるセグメントはmergeする。ASTは text / highlight、本文上の startPosition / endPosition / isStart / isEnd、ハイライト元の keywordIndex を持ち、省略記号や色はfrontendで決める。
KWICの文字列照合はNFKC・case・Latin diacriticの差を吸収する。Latin、Greek、Cyrillic、Number、Markの連続部分では単語途中の一致を除外するため、pro は profile 内ではハイライトしない。CJKには形態素解析を追加せず、部分一致の偽陽性を許容する。
内部の投稿・ユーザ検索はKWIC/Richを切り替えられ、外部公開投稿検索はKWIC/Rich/Plainを切り替えられる。デフォルトはKWICで、外部公開検索ではOldest切り替えを表示しない。
トランザクションとCOMMIT
TTTSは、文書タスクごとにFTS5のトランザクションをCOMMITせず、複数の追加・削除を一つのトランザクションにまとめる。対象シャードで最初の文書タスクを処理する時に BEGIN し、その後のタスクを同じトランザクションへ追加する。
自動COMMITの条件は以下の通りである。
- 未COMMITの更新数が10000件以上
- トランザクション開始から60秒以上
ただし、現在の実装では文書タスクの直後に毎回条件を判定するのではない。最新シャードは30秒ごと、全シャードは600秒ごとに条件を確認する。また、flush、optimize、reserve、reconstruct、正常終了など、整合性上COMMITが必要な操作の前にもCOMMITする。
更新をまとめると、FTS5のセグメント作成やWALへの書き込みを効率化できる。その代わり、TTTSが文書要求を受理してから検索可能になるまでに遅延が発生する。検索インデックスは結果整合性を前提としている。
SQLiteの主要設定は以下の通りである。
page_size = 8192 bytes journal_mode = WAL synchronous = NORMAL busy_timeout = 5000 ms journal_size_limit= 64 MiB FTS block size = 8000 bytes
WALを使うことで、writerが更新中でもreader接続から検索できる。synchronous=NORMAL は、完全同期より書き込み性能を優先しつつ、SQLiteのトランザクション整合性を利用する設定である。
世代ごとの接続数とキャッシュ
新しいシャードほど検索される頻度が高いため、世代によってSQLite接続数とキャッシュ量を変えている。
デフォルト設定は以下の通りである。
| *世代* | *reader接続数* | *mmap* | *SQLiteキャッシュ* | *FTS automerge* |
| 最新 | 2 | 256MiB | 24MiB | 8 |
| 1世代前 | 2 | 256MiB | 24MiB | 2 |
| 2世代前 | 1 | 0 | 400KiB | 2 |
| 3世代前 | 1 | 0 | 400KiB | 2 |
| それ以前 | 0 | 0 | 400KiB | 2 |
reader接続が0の古いシャードでは、writer接続を検索にも使う。頻繁に検索される最新の二世代には複数の読み取り専用接続とmmapを割り当て、古いシャードの常駐メモリを抑える。
readerはラウンドロビンで選ばれる。複数の検索要求が同時に来た場合でも、最新シャードの単一接続へ集中しにくい。シャード構成が変わると、世代順に接続数とキャッシュ設定を再適用する。
HTTP APIとwait
TTTSの文書更新は非同期である。文書追加の例を以下に示す。
curl -i -X PUT 'http://localhost:3200/posts/19F998DD11300000' \
-H 'Content-Type: application/json' \
-d '{
"text": "全文検索の対象となる本文",
"timestamp": 1780000000,
"locale": "ja",
"attrs": null,
"labels": ["owner:0000000000020101"],
"numericValue": 1780000000000
}'PUTの attrs、labels、numericValue は完全snapshotを構成する必須フィールドである。PUTは部分更新ではなく、その文書の最新状態を表す完全snapshotとして扱う。TTTS内部のdocument queueにも同じ完全snapshotを保存し、SQLiteへ反映する直前にだけ現在値との差分を調べる。recordContents=true なら本文のcanonical tokens 列とsynthetic化した labels 列がともに同一ならFTS5を書き換えず、recordContents=false では本文を常に新旧不一致とみなしてFTS5を再反映する。attrs=null、labels=[]、numericValue=null も明示的な現在状態である。
検索APIでは、label を複数回指定するとAND条件になり、numericOp と numericValue を組にして数値filterを指定できる。例えば ?label=owner%3A0000000000020101&numericOp=lte&numericValue=1780000000000 のように使う。これらのfilterは通常の全文検索queryを絞り込むためのものであり、TTTSでは引き続き空でない本文queryを必須とする。
wait を指定しなければ、TTTSのキューへ登録した時点で 202 Accepted を返す。
{
"result": "accepted",
"taskId": "d-123"
}wait に正の秒数を指定すると、その時間だけタスクの完了を待つ。小数も指定できる。
?wait=0.25 ?wait=1.5
時間内に完了すれば 200 OK と completed を返す。文書タスクの場合、タスクが batch_tasks から削除されるのは対応するインデックストランザクションのCOMMIT後なので、正常完了は検索インデックスへの永続化まで進んだことを意味する。
時間内に完了しなければ 408 Request Timeout を返す。
{
"result": "timeout",
"taskId": "d-123",
"message": "The task is still being processed"
}408になってもタスクは取り消されない。バックグラウンド処理は継続する。wait は処理全体のキャンセル期限ではなく、HTTPクライアントが同期的に待つ時間の上限である。
主な管理APIは以下の通りである。リソース部分には posts または users が入る。
GET /{resource}/maintenance: メンテナンスモードの確認POST /{resource}/maintenance: メンテナンスモードの開始DELETE /{resource}/maintenance: メンテナンスモードの終了POST /{resource}/flush: 全シャードをCOMMITPOST /{resource}/optimize: 指定シャードのFTS5最適化とVACUUMPOST /{resource}/reconstruct: 指定シャードを別ファイルへ再構築POST /{resource}/reserve: 文書IDに対応するinternal_idを予約GET /{resource}/shards: シャード一覧DELETE /{resource}/shards/{timestamp}: シャード削除DELETE /{resource}/queue: タスクキューの消去GET /{resource}/tokenize: トークナイズ結果の確認GET /{resource}/search:{tokens, phrases, result}を返し、resultに文書ID配列を格納GET /{resource}/search-fetch:{tokens, phrases, result}を返し、resultにTTTS内の文書配列を格納
queueの消去とreserveはメンテナンスモードを必要とする。通常の文書追加・削除を停止して、管理操作と競合しない状態で実行するためである。
インデックスの再構築
検索インデックスはPostgreSQLの派生データなので、いつでも全体を再構築できる。リポジトリ直下では以下のコマンドを使う。
npm run reset-search
このコマンドは、ユーザと投稿の両方について searchUtil のreset処理を実行する。大まかな手順は以下の通りである。
メンテナンスモードを開始 ↓ TTTS内部のタスクキューを消去 ↓ 既存シャードを削除 ↓ PostgreSQLのIDを古い順に走査してinternal_idを予約 ↓ メンテナンスモードを終了 ↓ PostgreSQLの文書を新しい順にTTTSへ再投入 ↓ flushしてCOMMIT
internal_idを先に予約するのは、再投入の並列度や処理順が変わっても、文書の作成順と rowid ASC の順序を一致させるためである。Snowflake IDを古い順に予約し、新しい文書ほど小さいinternal_idを割り当てる。
不健全なシャードだけを作り直す場合は以下を使う。
npm run reset-search-restore
この場合、詳細付きシャード一覧で開けなかったファイルだけを削除し、その時刻範囲の文書をPostgreSQLから再投入する。
TTTSの reconstruct APIは、PostgreSQLから文書を読み直す処理とは異なる。既存シャード内の文書とトークン列を新しいSQLiteファイルへコピーし、internal_idを振り直してFTS5をoptimizeした後、元ファイルと置き換える。論理データを変えずに、内部IDの余裕を作ったり、ファイルを圧縮したりするための機能である。
障害時の挙動
検索インデックスの更新には複数のサービスが関わるので、どの時点でもプロセス停止や通信障害が起こり得る。現在の構成では、更新要求を失うより重複実行を許容する方針を採っている。
主な障害位置と復旧方法は以下の通りである。
searchIndexWorkerがTTTSへの送信前に停止した場合
PostgreSQLの search_indexing_tasks に要求が残る。再起動後に同じ要求を取得する。
TTTSが要求を受理した後、PostgreSQL側のタスク削除前にワーカーが停止した場合
PostgreSQL側にもTTTS側にも要求が残るため、再送される可能性がある。同じ文書IDへの追加・削除は冪等なので、最終状態は変わらない。
TTTSがFTS5のCOMMIT前に停止した場合
SQLiteの未COMMIT更新はロールバックされるが、commonデータベースの batch_tasks に要求が残る。次回起動時に再実行される。
FTS5のCOMMIT後、batch_tasksの削除前に停止した場合
次回起動時に同じ要求が再実行される。これも冪等なので、文書が二重に登録されることはない。
検索インデックスファイルが破損または消失した場合
PostgreSQLが正本なので、該当シャードまたは全体を再構築する。検索インデックスのバックアップだけを特別に厳重管理する必要はない。ただし、再構築中は検索結果が欠けたり、全文検索が利用できなかったりする。
この設計でも、PostgreSQLとTTTSが常に同じ瞬間の状態になるわけではない。ワーカーの待機時間、TTTS内部のCOMMIT待ち、Redisキャッシュの有効期間があるため、検索結果への反映には遅延がある。全文検索は結果整合性の機能であり、投稿作成直後に必ず検索で見つかることを保証する機能ではない。
性能上の特徴
TTTSは以下を重視する。
- 時系列シャードを新しい順に検索し、必要件数で早期終了する。
- owner等のlabelをFTS5で本文条件と交差し、探索空間を先に狭める。
CROSS JOINでFTS5を外側ループに固定し、属性確認は候補ごとの主キー検索にする。- 数値条件は選択性の低いB-tree探索の起点にせず、FTS候補へ逐次適用する。
- Redisキャッシュと検索件数上限で繰り返し処理を抑える。
- TTTS検索で実際に使った正規化済みトークン列と完全なフレーズ単位列を検索結果と一緒に返し、KWICではフレーズ単位列ごとのRedis HASHを先に参照して、cache missした表示文書だけを後処理し、TTTSの検索計算量を増やさない。
関連度ランキングは行わず、通常のTTTS候補順は作成時刻の新しい順である。
制限事項
- 関連度スコア、OR/NOT、ワイルドカード、曖昧検索は提供しない。
recordPositions=falseかつrecordContents=trueでは、引用フレーズに加え、autoPhraseCheck=trueのときに複数トークンへ分割されたCJK入力単位も候補取得後にcanonical token列で確認する。recordContents=falseでは連続性を確認できないため、これらはAND条件へ退化する。- numeric filterの通過率が低い場合は、必要件数より多くのFTS候補を調べる。
- 検索インデックスとRedisキャッシュは結果整合性であり、最新状態の最終確認はPostgreSQLで行う。
- 匿名APIのquery hashは認証ではなく、直接HTTPを実装したbotはCaddyと処理時間制限で抑制する。
まとめ
STGYの全文検索は、SQLite FTS5を時系列シャードへ分割するTTTSを副次インデックスとして使う。投稿には owner:<userId> labelと公開日時の numericValue を持たせ、FTS5→主キー属性確認の実行順を固定して、探索空間を限定しても早期終了性を維持する。
外部公開検索ではownerと published_at <= now をTTTSとPostgreSQLの両方で確認し、匿名APIには X-STGY-QueryHash、CORS、CaddyのIPレート制限、共有処理時間制限を重ねる。KWICはTTTSから分離し、TTTS検索レスポンスの完全な phrases 列を使う。ordered phrases 単位のRedis HASHを再利用し、cache missした表示対象文書だけをMarkdown原文からASTへ変換する。検索インデックスはPostgreSQLから再構築可能な派生データとして運用する。