検索機能の内部実装
プリザンターの横断検索と一覧の検索は、各テーブルの実カラムではなく Items.FullText という非正規化された 1 本の文字列を検索しています。 このページでは、検索の入口の違い、Items.FullText がどう作られるか(なぜ「検索に引っかからない」ことがあるのか)、RDBMS ごとに生成される SQL とヒットの仕方の違い、一覧の検索方式と絞り込みの仕組み、横断検索と添付ファイルの中身の検索の仕組み、そしてそれらを踏まえて本体を改変せずに打てる手を整理します。
実践上の結論は「検索したいものは Items.FullText に載せる」です。Items.FullText に対する検索は、添付ファイルの保存先や RDBMS の種類に関係なく動きます(「内部実装から導く検索の改善策」を参照)。
対象バージョン
バージョン 1.5.7.0 のソースコードを対象にしています。確認したソースでも、Search.json の既定値(Search.json)、一覧の検索条件を作る View.SetSearchWhere()(View.cs#L3461-L3492)、MCP の SearchTypes と ParseSearchType()(SearchUtilities.cs#L18-L57)、SiteSettings.SearchTypes(SiteSettings.cs#L36-L42)が同じであることを確認しています。
検索の入口は 4 系統ある
UI 上で「検索」と呼ばれているものは、実装としては 4 系統に分かれています。
| 入口 | UI 上の場所 | 実装の入口 | 最終的に見ている列 |
|---|---|---|---|
| 横断検索 | ヘッダの検索ボックス | Indexes.Search / SearchJson | Items.FullText |
| 一覧の検索 | 一覧上部の検索ボックス | View.Search → SetSearchWhere | Items.FullText / Items.Title |
| 項目ごとの絞り込み | フィルタ列 | View.ColumnFilterHash | 各テーブルの実カラム |
| 選択肢の検索 | リンク項目のドロップダウン | SearchDropDown | リンク先の Items.Title |
「ヘッダの検索では出るのに一覧の検索では出ない」という現象は、そもそも見ている列も条件式も違うために起こります。
このほかに、MCP サーバ経由の検索(MCP/Utilities/SearchUtilities.cs)もあります(後述の「MCP サーバの検索と 3 つの SearchTypes」を参照)。
検索の土台は Items.FullText 列
レコードの実体は、テーブルの種類によって Issues・Results・Wikis・Sites・Dashboards に分かれて格納されています。 しかし検索はそれらのテーブルを直接見にいかず、Items テーブルの FullText 列に検索用のテキストを 1 本の文字列として展開して持たせています。
図を読み込み中…
このように非正規化している理由は次のとおりです。
- 拡張項目が横に長い(
ClassA〜ClassZ、NumA〜NumZなど)ため、実テーブルを対象にすると検索条件が列数分だけ膨れ上がる(Enterprise Edition の項目拡張を使っている環境ではさらに増える) - 参照タイプが 5 種類あるため、テーブルごとに別の SQL を書くことになる
- 横断検索では、異種テーブルのレコードを 1 回のクエリでまとめて検索したい
Items に文字列を 1 本持たせておけば、テーブルの種類を問わず同じ条件式で検索できます。
Items テーブルの検索関連列
| 列 | 役割 |
|---|---|
ReferenceId | レコードの ID(IssueId / ResultId など) |
ReferenceType | 参照タイプ(Issues / Results / Wikis / Sites / Dashboards) |
SiteId | 所属サイト |
Title | レコードのタイトル |
FullText | 検索用に展開したテキスト |
SearchIndexCreatedTime | FullText を生成した時刻 |
SearchIndexCreatedTime を UpdatedTime と比較することで、「インデックスが古いレコード」を判定しています。
インデックスが作られる 3 つの経路
Items.FullText は自動で常に最新になるわけではなく、次の 3 つの経路で生成されます。
| 経路 | 契機 | 制御するパラメータ |
|---|---|---|
| 同期生成 | レコードの作成・更新時 | Search.json の CreateIndexes(既定 true) |
| バックグラウンド | 生成時刻が未設定・不一致のものを拾う | BackgroundTask.json の CreateSearchIndexLot(既定 100) |
| 手動再構築 | サイトの設定の「検索インデックスの再構築」 | なし |
実際に Items.FullText を書き込んでいるのは次のメソッドです。
private static void CreateFullText(Context context, long id, string fullText)
{
if (fullText != null)
{
Repository.ExecuteNonQuery(
context: context,
statements: Rds.UpdateItems(
where: Rds.ItemsWhere().ReferenceId(id),
param: Rds.ItemsParam()
.FullText(fullText)
.SearchIndexCreatedTime(DateTime.Now),
addUpdatorParam: false,
addUpdatedTimeParam: false));
}
}addUpdatorParam: false と addUpdatedTimeParam: false が指定されているため、インデックスを作り直しても更新者と更新日時は変わりません。
CreateIndexes を false にした場合
Search.json の CreateIndexes を false にすると同期生成が止まります。各モデルの FullText() メソッドの先頭に次の判定があります。
if (!Parameters.Search.CreateIndexes && !backgroundTask) return null;null が返ると CreateFullText() は何も書き込みません。ただし backgroundTask: true で呼ばれた場合は素通りするので、バックグラウンド生成と手動再構築は CreateIndexes の設定に関係なく動きます。 このため、大量データを一気に投入するときだけ CreateIndexes を false にして、あとからまとめて再構築する運用が可能です。
再構築の 2 つのモード
再構築処理は、サイト指定かどうかで対象が変わります。
where: siteId > 0
? Rds.ItemsWhere().SiteId(siteId)
: Rds.ItemsWhere().Add(raw: new List<string>()
{
"\"Items\".\"SearchIndexCreatedTime\" is null",
"\"Items\".\"SearchIndexCreatedTime\"<>\"Items\".\"UpdatedTime\""
}.Join(" or ")),
top: siteId > 0
? 0
: Parameters.BackgroundTask.CreateSearchIndexLot))| モード | 対象 | 件数上限 |
|---|---|---|
siteId > 0(サイト指定。サイトの設定のボタン) | そのサイトの全レコード | なし |
siteId <= 0(バックグラウンドタスク) | 生成時刻が未設定または UpdatedTime と不一致 | CreateSearchIndexLot 件 |
サイト指定は上限なしで全件を処理するため、レコード数が多いサイトでは実行時間に注意してください。
また、再構築の前に全サイト分の権限ハッシュを「読み取り可」で埋めています。インデックスの生成にはリンク先の表示名の解決などで権限チェックを通す必要があるためで、「インデックス生成のためだけに全サイト読み取り可として扱う」実装になっています。
context.PermissionHash = Rds.ExecuteTable(
context: context,
statements: Rds.SelectSites(
column: Rds.SitesColumn().SiteId()))
.AsEnumerable()
.ToDictionary(
o => o.Long("SiteId"),
o => Permissions.Types.Read);Search.json のパラメータ
検索の挙動は App_Data/Parameters/Search.json でまとめて制御されています。既定値は次のとおりです。
{
"SearchDocuments": false,
"CreateIndexes": true,
"PageSize": 20,
"DisableCrossSearch": false,
"DisableCrossSearchSites": false,
"FullTextIncludeBreadcrumb": false,
"FullTextIncludeSiteId": false,
"FullTextIncludeSiteTitle": true,
"FullTextNumberOfMails": 10,
"FullTextMaxNumberOfMails": 100
}| パラメータ | 既定 | 内容 |
|---|---|---|
SearchDocuments | false | 添付ファイルの中身も検索対象にするか |
CreateIndexes | true | 作成・更新時にインデックスを生成するか |
PageSize | 20 | 横断検索の 1 ページあたり件数 |
DisableCrossSearch | false | 横断検索そのものを無効化するか |
DisableCrossSearchSites | false | テーブル定義自体を結果から外すか |
FullTextIncludeBreadcrumb | false | パンくずをインデックスに含めるか |
FullTextIncludeSiteId | false | サイト ID を含めるか |
FullTextIncludeSiteTitle | true | サイトのタイトルを含めるか |
FullTextNumberOfMails | 10 | インデックスに含める送信メール数 |
FullTextMaxNumberOfMails | 100 | 送信メール数の上限 |
パラメータクラス(Implem.ParameterAccessor/Parts/Search.cs)は JSON のキーと 1 対 1 で対応しています。
Items.FullText の組み立て
Items.FullText の中身を組み立てているのは各モデルの FullText() メソッドです。IssueModel の例です。
public string FullText(
Context context,
SiteSettings ss,
bool backgroundTask = false,
bool onCreating = false)
{
if (!Parameters.Search.CreateIndexes && !backgroundTask) return null;
if (AccessStatus == Databases.AccessStatuses.NotFound) return null;
var fullText = new System.Text.StringBuilder();
if (ss.FullTextIncludeBreadcrumb == true)
{
// パンくずを連結
}
if (ss.FullTextIncludeSiteId == true)
{
fullText.Append($" {ss.SiteId}");
}
if (ss.FullTextIncludeSiteTitle == true)
{
fullText.Append($" {ss.Title}");
}
// 以下、項目ごとの連結が続くStringBuilder に空白区切りで値を積んでいく作りで、連結の順番は次のとおりです。
図を読み込み中…
先頭に付く 3 つの任意情報
| パラメータ | 既定 | 連結される内容 |
|---|---|---|
FullTextIncludeBreadcrumb | false | 親サイトをたどったパンくず |
FullTextIncludeSiteId | false | サイト ID の数値 |
FullTextIncludeSiteTitle | true | サイトのタイトル |
FullTextIncludeSiteTitle だけが既定で有効です。そのため横断検索でサイト名を入れると、そのサイトのレコードが全部ヒットします。検索結果がノイズだらけになる場合はここを確認してください。
これらの既定値は Search.json から SiteSettings に流れ込んだうえで、サイトごとに上書きできる形になっています。
FullTextIncludeBreadcrumb = FullTextIncludeBreadcrumb ?? Parameters.Search.FullTextIncludeBreadcrumb;コメントと送信メール
コメントは投稿者名と本文の両方が入ります(FullTextExtensions.cs#L378-L403)。やりとりが長いレコードでは Items.FullText が膨らむ要因になります。
送信メールの内容もインデックスに入ります。
if (ss.FullTextNumberOfMails > 0)
{
new OutgoingMailCollection(
context: context,
where: Rds.OutgoingMailsWhere()
.ReferenceType(referenceType)
.ReferenceId(referenceId),
orderBy: Rds.OutgoingMailsOrderBy()
.OutgoingMailId(orderType: SqlOrderBy.Types.desc),
top: ss.FullTextNumberOfMails.ToInt())
.ForEach(o =>
{
// From / To / Cc / Bcc / Title / Body を連結
});
}FullTextExtensions.cs#L460-L499
宛先やメール本文まで含まれるので、メール送信機能を使っているサイトでは Items.FullText がかなり大きくなります。不要なら FullTextNumberOfMails を 0 にすると連結処理そのものがスキップされます。 なお onCreating が true のとき(レコード作成時)は、まだメールが存在しないためこの処理は呼ばれません。
最後の正規化:FullText は「語の集合」
FullText() の戻り値は最後に次のように正規化されます。
return fullText
.ToString()
.Replace(" ", " ")
.Replace("\r", " ")
.Replace("\n", " ")
.Split(' ')
.Select(o => o.Trim())
.Where(o => o != string.Empty)
.Distinct()
.Join(" ");全角スペースと改行を半角スペースに変換して分割し、空要素を除いて Distinct() してから再結合しています。つまり Items.FullText は、元の文章そのものではなく重複を除いた語の集合です。
| 入力 | Items.FullText に入る値 |
|---|---|
対応 完了 対応 完了 | 対応 完了 |
改行を(改行)含む本文 | 改行を 含む本文 |
- 同じ単語が何度出てきても 1 回しか格納されないので、出現回数による重み付けはできません
- 空白や改行で区切られた位置は保持されないので、改行をまたぐフレーズ検索は期待どおりに動きません
- 日本語は単語をスペースで区切らないため、日本語の本文はほぼそのまま 1 語として格納されます。本文の途中の単語で検索できるかどうかは、後述の RDBMS 側の分割方式に委ねられています
「更新したのに検索に引っかからない」3 つの原因
検索に載らない原因は、次の 3 つに集約されます。
- その項目が編集画面に配置されていない
- その項目の全文検索の設定が
なしのままになっている - 設定を変えたが既存レコードのインデックスを再構築していない
原因 1:編集画面に配置された項目だけが対象
項目の値を連結するループは GetEditorColumnNames() が返す列、つまり編集画面に配置されている項目だけを対象にしています。
ss.GetEditorColumnNames(
context: context,
columnOnly: true)
.Select(columnName => ss.GetColumn(
context: context,
columnName: columnName))
.ForEach(column =>
{
switch (column.ColumnName)
{
case "Title":
Title.FullText(
context: context,
column: column,
fullText: fullText);
break;
// 以下、項目ごとに続く
}
});WARNING
一覧には表示しているけれど編集画面には配置していない項目は、Items.FullText に入りません。つまり検索できません。
検索したい項目は、画面上で使わないとしても編集画面に配置したうえで非表示にするのが正解です。配置から外すとインデックスの対象から消えます。
原因 2:拡張項目の全文検索は既定で無効
項目ごとの全文検索の扱いは Column.FullTextType で決まります。
public enum FullTextTypes : int
{
None = 0,
DisplayName = 1,
Value = 2,
ValueAndDisplayName = 3
}設定されていないときは列定義の既定値が使われます(SiteSettings.cs#L2198)。
column.FullTextType = column.FullTextType ?? (Column.FullTextTypes)columnDefinition.FullTextType;Title や Body、Manager、Owner、Status などの標準項目は列定義ファイルに FullTextType が明示されています。
{
"Id": "_BaseItems_Body",
"ColumnName": "Body",
"FullTextType": "1"
}一方、拡張項目の列定義には FullTextType の記載がなく、既定値の 0(None)になります。
| 項目 | 既定の FullTextType |
|---|---|
Title / Body | DisplayName(1) |
Manager / Owner / Status | DisplayName(1) |
ClassA〜ClassZ | None(0) |
NumA〜NumZ | None(0) |
DescriptionA〜DescriptionZ | None(0) |
AttachmentsA〜AttachmentsZ | None(0) |
WARNING
拡張項目は、サイトの設定で明示的に指定しない限り全文検索の対象になりません。Enterprise Edition の項目拡張で Class001〜Class999 のような項目を増やした場合も、既定は同じく None です。
設定場所は「サイトの設定」→「項目」→ 対象項目の詳細 →「全文検索」です。選択肢は ColumnUtilities.FullTextTypeOptions()(ColumnUtilities.cs#L93-L102)で作られており、なし / 表示名 / 値 / 値と表示名 の 4 つです。
原因 3:設定変更は既存レコードに遡及しない
FullTextType を変更しても、既存レコードの Items.FullText は次に更新されるまで変わりません。サイトの設定から「検索インデックスの再構築」を実行する必要があります(前述の「再構築の 2 つのモード」を参照)。
検索できない項目を検索できるようにする手順
- 対象項目を編集画面に配置する(使わない項目なら非表示にする)
- 項目の詳細で**「全文検索」を
表示名以上に設定**する - サイトの設定から**「検索インデックスの再構築」を実行**する
3 番目を忘れると「新しく更新したレコードだけ検索できる」という中途半端な状態になります。リンク項目を表示名で検索したい場合は、あわせて UseSearch の設定も確認してください(後述の「リンク項目のドロップダウン検索」を参照)。
FullTextTypes の選び方
表示名 と 値 の違いは、項目の型によって意味が変わります(FullTextExtensions.cs の型ごとの実装)。
| 項目の型 | 表示名 | 値 | おすすめ・備考 |
|---|---|---|---|
| ユーザ項目 | ユーザ名 | ユーザ ID(数値) | 表示名。氏名で検索したいなら必須 |
| 分類項目 | 選択肢の表示値 | コード値 | 値と表示名。コードでも名称でも引っかけたい場合 |
| リンク項目 | リンク先のタイトル | リンク先の ID | 表示名。UseSearch も確認する |
| 添付ファイル項目 | ファイル名 | GUID | 表示名。ファイルの中身は入らない |
| 数値項目 | 数値 | 数値 | どちらでも同じ |
ユーザ項目の実装です。
case Column.FullTextTypes.DisplayName:
fullText
.Append(" ")
.Append(user.Anonymous()
? string.Empty
: user.Name);
break;
case Column.FullTextTypes.Value:
fullText
.Append(" ")
.Append(user.Anonymous()
? string.Empty
: user.Id.ToString());
break;FullTextExtensions.cs#L86-L112
添付ファイル項目は 表示名 で o.Name、値 で o.Guid、値と表示名 で両方を連結します(FullTextExtensions.cs#L425-L458)。添付ファイル項目も拡張項目なので既定は None で、既定ではファイル名でも検索できません。表示名 にしておくと、保存先や RDBMS の種類を問わずファイル名で検索できるようになります。値 を選ぶと GUID しか入らないため、ファイル名では検索できません。ファイルの中身の検索はまったく別の仕組み(SearchDocuments。後述の「添付ファイルの中身の検索」を参照)です。
検索語が SQL パラメータになるまで
入力された文字列は 4 段階の加工を経て SQL のパラメータになります。
図を読み込み中…
第 1 段階:SearchIndexes()
全角スペースを半角に変換して分割し、Distinct() します。同じ語を 2 回入れても 1 回として扱われます。
public static List<string> SearchIndexes(this string self)
{
return self?.Replace(" ", " ")
.Split(' ')
.Select(o => o.Trim())
.Where(o => o != string.Empty)
.Distinct()
.ToList()
?? new List<string>();
}SearchIndexExtensions.cs#L7-L17
第 2 段階:Words()
分割した語を SQL パラメータの辞書に変換します。
private static Dictionary<string, string> Words(string searchText)
{
return searchText?
.Replace(" ", " ")
.Replace("\"", " ")
.Replace("'", "’")
.Trim()
.Split(' ')
.Where(o => o != string.Empty)
.Distinct()
.Select(o => FullTextClause(o))
.ToDictionary(o => Strings.NewGuid(), o => o);
}| 文字 | 処理 | 理由 |
|---|---|---|
"(半角ダブルクォート) | スペースに置換 | 後段でフレーズを囲むのに使うため |
'(半角シングルクォート) | ’(全角)に置換 | SQL リテラルの区切りと衝突するため |
検索語にダブルクォートを入れても引用符としては機能せず、区切りとして消えます。 パラメータ名には Strings.NewGuid() が使われるため、SQL デバッガーで生成された SQL を見ると @a1b2c3... のようなランダムな名前が並びます。
第 3 段階:FullTextClause()(かな変換と前方一致)
private static string FullTextClause(string word)
{
var data = new List<string> { word };
var katakana = CSharp.Japanese.Kanaxs.KanaEx.ToKatakana(word);
var hiragana = CSharp.Japanese.Kanaxs.KanaEx.ToHiragana(word);
if (word != katakana) data.Add(katakana);
if (word != hiragana) data.Add(hiragana);
return "(" + data
.SelectMany(part => new List<string>
{
part,
ForwardMatchSearch(part: part)
})
.Where(o => o != null)
.Distinct()
.Select(o => "\"" + o + "\"")
.Join(" or ") + ")";
}KanaExでひらがなとカタカナの相互変換を行い、表記ゆれを吸収する- それぞれについて前方一致用の
*付きも作り、すべてをorで連結する
はんばい → ("はんばい" or "はんばい*" or "ハンバイ" or "ハンバイ*")
販売 → ("販売" or "販売*")前方一致を付ける条件:ForwardMatchSearch()
前方一致用の * を付けるかどうかは次のメソッドで決まります。
private static string ForwardMatchSearch(string part)
{
var separators = "!#$%&()*+,-./:<=>?@[\\]^_`{|}~";
foreach (var separator in separators)
{
if (part.Split(separator).Any(o => o.RegexExists("^[0-9]$")))
{
return null;
}
}
return part + "*";
}記号で分割した断片のなかに1 桁の数字だけの要素があると、前方一致をあきらめます。型番やバージョン表記で効いてきます。
| 検索語 | 記号で分割 | 1 桁数字を含むか | 前方一致 |
|---|---|---|---|
販売 | 販売 | 含まない | 付く |
A-1 | A / 1 | 含む | 付かない |
v1.0 | v1 / 0 | 含む | 付かない |
AB-12 | AB / 12 | 含まない | 付く |
A-1 で検索しても A-1234 のようなレコードは引っかかりませんが、AB-12 なら AB-1234 も拾えます。「品番の一部で検索したのに出てこない」現象の理由です。
RDBMS 別の全文検索 SQL
RDBMS ごとの切り替えは ISqlCommandText インタフェースの 3 メソッドで行われ、この実装差がそのまま検索の挙動差になります。
string CreateFullTextWhereItem(string itemsTableName, string paramName, bool negative);
string CreateFullTextWhereBinary(string itemsTableName, string paramName, bool negative);
Dictionary<string,string> CreateSearchTextWords(Dictionary<string,string> words, string searchText);SQL Server
条件式は contains() 述語です。#CommandCount# は、1 回の実行で複数の SQL を投げるときにパラメータ名が衝突しないよう連番を差し込むプレースホルダです。
return (negative
? $"(not contains(\"{itemsTableName}\".\"FullText\", @{paramName}#CommandCount#))"
: $"(contains(\"{itemsTableName}\".\"FullText\", @{paramName}#CommandCount#))");SqlServerCommandText.cs#L81-L89
CreateSearchTextWords() は受け取った辞書をそのまま返します(SqlServerCommandText.cs#L101-L106)。語ごとに 1 パラメータとなり、語ごとに条件が追加されるので、スペース区切りが AND として効きます。
インデックスの DDL です。
CREATE FULLTEXT CATALOG ftx
WITH ACCENT_SENSITIVITY = OFF;
CREATE FULLTEXT INDEX ON [Items]
([FullText] Language 'Japanese')
KEY INDEX #PKItems#
ON ftx;CreateFullText.sql(SQL Server)
- カタログ名は
ftx固定で、ACCENT_SENSITIVITY = OFF Language 'Japanese'を指定しているので、日本語のワードブレーカーが必要#PKItems#は実行時にSelectPkName.sqlで主キー名を引いて差し替えられる
日本語のワードブレーカーが単語を切るので、Items.FullText に日本語の文章がそのまま入っていても単語単位で検索できます。
PostgreSQL
return (negative
? $"(not (coalesce(\"{itemsTableName}\".\"FullText\",'') %> @{paramName}#CommandCount#))"
: $"(\"{itemsTableName}\".\"FullText\" %> @{paramName}#CommandCount#)");PostgreSqlCommandText.cs#L93-L101
%> は pg_trgm 拡張が提供する単語類似度演算子で、全文検索述語ではなくトライグラムによる類似判定です。否定側だけ coalesce() で NULL を空文字に落としています。
インデックスは GIN + gin_trgm_ops で、pg_trgm 拡張はスキーマ作成時(CreateSchema.sql の create extension if not exists pg_trgm;)に入ります。
create index if not exists "ftx" on "Items" using gin ("FullText" gin_trgm_ops);CreateFullText.sql(PostgreSQL)
PostgreSQL 版だけ CreateSearchTextWords() の中身がまったく別物です。
public Dictionary<string,string> CreateSearchTextWords(
Dictionary<string,string> words,
string searchText)
{
if (searchText.IsNullOrWhiteSpace()) return new();
return new Dictionary<string, string> { [Strings.NewGuid()] = searchText };
}PostgreSqlCommandText.cs#L113-L118
受け取った words を捨て、検索文字列を丸ごと 1 パラメータにしています。 そのため FullTextClause() で作ったかな変換や前方一致の展開は結果的に使われず、%> に渡されるのはユーザが入力した生の文字列です。
さらに %> は pg_trgm.word_similarity_threshold(既定 0.6)を閾値として判定します。検索文字列が長くなるほど類似度は下がりやすいため、語数を増やすとヒットしなくなる挙動になります。
WARNING
PostgreSQL では「単語を足して絞り込む」使い方が期待どおりに動きません。SQL Server が AND で絞り込むのに対し、PostgreSQL は文字列全体の類似度で判定するためです。
MySQL
return (negative
? $@"not match(""{itemsTableName}"".""FullText"") against (@{paramName}#CommandCount# in boolean mode)"
: $@"match(""{itemsTableName}"".""FullText"") against (@{paramName}#CommandCount# in boolean mode)");MATCH ... AGAINST を BOOLEAN MODE で使います。CreateSearchTextWords() は SQL Server と同じく素通しなので、語ごとに 1 パラメータです。
create fulltext index "ftx" on "Items"("FullText") with parser "ngram";ngram パーサで日本語も分割されますが、ngram_token_size(既定 2)より短い語は索引に載らないため、1 文字での検索は効きません。 また BOOLEAN MODE では * が前方一致、" がフレーズ指定として解釈されます。FullTextClause() が生成する ("販売" or "販売*") という文字列は BOOLEAN MODE の構文としても意味を持ってしまうため、実際の挙動は環境で検証してから判断するのが安全です。
MariaDB には ngram パーサーが無いため、Dbms: "MySQL" で MariaDB に CodeDefiner を実行すると、この全文索引の作成が失敗します(テーブルの作成は続行されます)。MariaDB や TiDB で使う場合の対応案は MariaDB・TiDB で動かす にあります。
3 つの RDBMS の比較
| 項目 | SQL Server | PostgreSQL | MySQL |
|---|---|---|---|
| 条件式 | contains(FullText, @p) | FullText %> @p | match(FullText) against (@p in boolean mode) |
| 索引の種類 | Full-Text Catalog | GIN + pg_trgm | FULLTEXT + ngram |
| 日本語の分割 | ワードブレーカー | トライグラム | N-gram |
| 検索語の前処理 | 素通し | 1 パラメータに結合 | 素通し |
| スペース区切りの意味 | AND | 意味を持たない | AND |
| かな変換の反映 | される | されない | される |
| 短い語 | 分割に依存 | 3 文字未満は不利 | 2 文字未満は不可 |
| 閾値の概念 | なし | word_similarity_threshold | なし |
「開発は PostgreSQL、本番は SQL Server」という構成では、検索の挙動テストが本番環境の代わりになりません。
PostgreSQL の全文索引の確認方法や、pg_bigm を導入するときの注意点は 全文検索の環境構築 にまとめています。
一覧の検索の実装
一覧画面の検索ボックスは、既定では全文検索を使っていません。前節の RDBMS ごとの全文検索構文は、サイトの設定で検索方式を「全文検索」にしたときにだけ使われます。
サイト単位の検索方式は 4 種類
一覧の検索ボックスの挙動は、サイトの設定の検索方式で決まります。定義は SiteSettings.SearchTypes です。
public enum SearchTypes : int
{
FullText = 10,
PartialMatch = 15,
MatchInFrontOfTitle = 20,
BroadMatchOfTitle = 30,
}既定値は FullText ではなく PartialMatch です。
SearchType = SearchType ?? SearchTypes.PartialMatch;| 値 | サイトの設定での表示 | 対象列 | 生成される条件 | 全文索引 |
|---|---|---|---|---|
FullText(10) | 全文検索 | Items.FullText | RDBMS の全文検索構文 | 効く |
PartialMatch(15) | 部分一致(既定) | Items.FullText | like '%...%' | 効かない |
MatchInFrontOfTitle(20) | タイトル前方一致 | Items.Title | like '...%' | 効く |
BroadMatchOfTitle(30) | タイトル部分一致 | Items.Title | like '%...%' | 効かない |
分岐しているのは SearchTextWhere() です。
switch (ss?.SearchType)
{
case SiteSettings.SearchTypes.FullText:
// 全文検索の条件を組み立てる
case SiteSettings.SearchTypes.MatchInFrontOfTitle:
return where.SqlWhereLike(
tableName: "Items",
name: "SearchText",
searchText: searchText,
clauseCollection: Rds.Items_Title_WhereLike(
factory: context,
forward: true).ToSingleList());
case SiteSettings.SearchTypes.BroadMatchOfTitle:
// タイトル部分一致
case SiteSettings.SearchTypes.PartialMatch:
default:
return where.SqlWhereLike(
tableName: "Items",
name: "SearchText",
searchText: searchText,
clauseCollection: Rds.Items_FullText_WhereLike(
factory: context,
forward: false).ToSingleList());
}default が PartialMatch と同じ扱いなので、検索方式を設定していないサイトはすべて部分一致になります。
既定の PartialMatch は全文索引を使わない
PartialMatch は Items.FullText に対する like '%キーワード%' です。前方にワイルドカードが付くため、全文索引はまったく使われません。
図を読み込み中…
Items.FullText にはレコードのほぼ全項目とコメント、送信メールが連結されているので(「Items.FullText の組み立て」を参照)、かなり長い文字列です。その長い文字列に対して、全レコード分の前方ワイルドカード LIKE を実行することになります。
WARNING
既定設定のままでは、データが増えるほど一覧の検索が線形に遅くなります。「最初は速かったのに最近重い」場合は、まずここを疑ってください。
一方で PartialMatch は RDBMS の全文検索機能に依存しないので、索引が作られていない環境でも確実に動き、分割方式による取りこぼしもありません。既定値としては無難な選択です。
検索方式を「全文検索」に切り替えると速くなりますが、ヒットの仕方も変わります(後述の「一覧の検索を全文索引に乗せる」を参照)。
検索文字列の分解ルール(or と否定検索)
一覧の検索ボックスに入力した文字列は View.Search に入り、SetSearchWhere() が条件に変換します。
private void SetSearchWhere(
Context context,
SiteSettings ss,
SqlWhereCollection where,
bool itemJoin)
{
var negative = UseNegativeFilters(
ss: ss,
name: "ViewFilters_Search") == true;
var collection = new SqlWhereCollection();
Search?
.Replace(" ", " ")
.Replace(" or ", "\n")
.Split('\n')
.Where(o => !o.IsNullOrEmpty())
.ForEach(search =>
collection.Add(and: new SqlWhereCollection().FullTextWhere(
context: context,
ss: ss,
searchText: search,
itemJoin: itemJoin,
negative: negative)));
if (collection.Any())
if (negative)
{
where.Add(and: collection);
}
else
{
where.Add(or: collection);
}
}" or " を改行に置換してから分割し、分割された片どうしを OR で結合しています。各片の中のスペース区切りは、前述の SearchIndexes() で分割されて AND になります。
| 入力 | 解釈 |
|---|---|
販売 東京 | 販売 AND 東京 |
販売 or 東京 | 販売 OR 東京 |
販売 東京 or 大阪 | (販売 AND 東京) OR 大阪 |
置換の対象は " or " という前後にスペースを含む小文字の文字列です。
販売or東京は分割されません(orが語の一部として扱われます)販売 OR 東京も分割されません(大文字は対象外)orを含む英単語(colorなど)は、前後にスペースがないので影響を受けません
否定検索(negative が真)のときは、片どうしの結合が OR ではなく AND になります。「A を含まない」かつ「B を含まない」が否定検索で期待される結果で、OR にすると「A を含まないか、B を含まない」になってほぼ全件が該当してしまうためです。negative フラグは条件式の生成まで一貫して伝わります。
| RDBMS | 否定時の条件 |
|---|---|
| SQL Server | not contains(...) |
| PostgreSQL | not (coalesce(FullText,'') %> @p) |
| MySQL | not match(...) against(...) |
| LIKE 系 | not like |
itemJoin の有無で SQL の形が変わる
FullTextWhere() には itemJoin という引数があります。
public static SqlWhereCollection FullTextWhere(
this SqlWhereCollection where,
Context context,
SiteSettings ss,
string searchText,
bool itemJoin,
bool negative)この値によって、対象テーブル名の決め方が変わります。
private static string ItemTableName(SiteSettings ss, bool itemJoin)
{
if (itemJoin)
{
return ss.ReferenceType + "_Items";
}
else
{
switch (ss.TableType)
{
case Sqls.TableTypes.Deleted:
return "Items_deleted";
default:
return "Items";
}
}
}itemJoin が真なら Issues_Items のような結合済みの別名テーブルに直接条件を書き、偽なら相関サブクエリになります。
return $"exists(select * from \"{tableName}\" where \"{tableName}\".\"ReferenceId\"={ss.IdColumnBracket()} and {like})";itemJoin | 生成される形 | 特徴 |
|---|---|---|
true | Issues_Items.FullText like '%...%' | 結合済みなので条件が素直 |
false | exists(select * from "Items" where ...) | 相関サブクエリ |
同じ検索でも呼び出し元によって形が変わるので、実行計画を確認するときは、どちらの経路で生成された SQL なのかを先に把握しておくと混乱しません。生成された SQL は SQL デバッガーで確認できます(Operations Tools と SQL デバッガー)。
ゴミ箱と履歴では常に LIKE になる
SearchTextWhere() と FullTextWhere() は、どちらも冒頭でテーブル種別を見ています。
if (ss != null && ss.TableType != Sqls.TableTypes.Normal)
{
return where.ItemWhereLike(
context: context,
ss: ss,
columnName: "FullText",
searchText: searchText,
name: name,
forward: false,
itemJoin: itemJoin,
negative: negative);
}TableType が Normal 以外、つまりゴミ箱や履歴を見ているときは、検索方式の設定にかかわらず強制的に LIKE になります。CreateFullText.sql が索引を張るのは Items と Binaries だけで、Items_deleted テーブルには全文索引がないため、妥当な実装です。
Indexes.cs には、テーブル種別からサフィックスを組み立てる分岐もあります。
var suffix = string.Empty;
switch (tableType)
{
case Sqls.TableTypes.Deleted:
suffix = "_deleted";
break;
}このメソッドは全文検索述語を組み立てるものですが、ss.TableType != Normal のときは前段の分岐で LIKE に落ちているため、suffix が _deleted になる経路には到達しません。もし到達すると contains("Items_deleted"."FullText", @p) が生成され、索引がないため実行時エラーになります。現状は前段の分岐がそれを防いでいる構造です。
「常に検索条件を要求する」の判定式
サイトの設定の「常に検索条件を要求する」(AlwaysRequestSearchCondition)は、条件を指定しないまま全件が表示されるのを防ぐためのオプションです。
public bool RequestSearchCondition(Context context, SiteSettings ss)
{
var where = new SqlWhereCollection();
SetColumnsWhere(
context: context,
ss: ss,
where: where);
return (ss.AlwaysRequestSearchCondition == true)
&& (Incomplete != true
&& Own != true
&& NearCompletionTime != true
&& Delay != true
&& Overdue != true
&& where.Any() != true
&& Search.IsNullOrEmpty());
}判定に使われる要素は 7 つです。
| 要素 | 内容 |
|---|---|
Incomplete | 未完了フィルタ |
Own | 自分のレコードフィルタ |
NearCompletionTime | 完了間近フィルタ |
Delay | 遅延フィルタ |
Overdue | 期限超過フィルタ |
where.Any() | 列フィルタが 1 つでもあるか |
Search | 検索ボックスの入力 |
これらがすべて空のときだけ「検索条件を指定してください」と表示されます。逆に言えば、絞り込みボタンを 1 つ押しただけでも条件ありと判定されるので、「全件に近い表示」を完全に防げるわけではありません。
項目ごとの絞り込み:Column.SearchTypes
一覧のフィルタ列での絞り込みは、別の列挙体 Column.SearchTypes で制御されています。
public enum SearchTypes : int
{
PartialMatch = 1,
ExactMatch = 2,
ForwardMatch = 3,
PartialMatchMultiple = 11,
ExactMatchMultiple = 12,
ForwardMatchMultiple = 13
}Multiple が付いているものは、複数値を持つ項目(複数選択の分類項目など)向けです。
WARNING
SiteSettings.SearchTypes と Column.SearchTypes は名前が同じで中身が別物です。SiteSettings 側は検索ボックスの方式(4 種類)、Column 側はフィルタ列の一致方式(6 種類)です。MCP サーバにも同名の列挙体があります(後述の「MCP サーバの検索と 3 つの SearchTypes」を参照)。
ビューごとに列単位で一致方式を上書きする仕組みもあります。
public Dictionary<string, Column.SearchTypes> ColumnFilterSearchTypes;条件を組み立てるときは、ビューの設定を優先し、なければ列の設定にフォールバックします。
var searchType = ColumnFilterSearchTypes?.ContainsKey(column.ColumnName) == true
? ColumnFilterSearchTypes.Get(column.ColumnName)
: column.SearchType;同じ項目でも、ビューによって完全一致と部分一致を切り替えられます。
横断検索の実装
ヘッダの検索ボックスから使う横断検索は、一覧の検索とは実装がまったく別です。テーブルの種類をまたいでレコードを探せる代わりに、検索方式は常に全文検索固定で、サイトの設定の検索方式は効きません。
入口は Search() と SearchJson()
public static string Search(Context context)
{
if (Parameters.Search.DisableCrossSearch)
{
return HtmlTemplates.Error(
context: context,
errorData: new ErrorData(type: Error.Types.InvalidRequest));
}
var dataSet = Get(
context: context,
searchText: context.QueryStrings.Data("text"),
dataTableName: "SearchResults",
offset: context.QueryStrings.Int("offset"),
pageSize: Parameters.Search.PageSize);
return MainContainer(
context: context,
text: context.QueryStrings.Data("text"),
offset: 0,
results: dataSet?.Tables["SearchResults"].AsEnumerable(),
count: Rds.Count(dataSet)).ToString();
}| メソッド | 用途 |
|---|---|
Search() | 検索結果ページの HTML を返す |
SearchJson() | Ajax で追加読み込みするときの JSON を返す |
Search.json の DisableCrossSearch が true のときは、Search() の先頭でエラー画面を返して終了します。横断検索を機能ごと止めたい場合はここが効きます。
検索条件の組み立て
Get() メソッドでは、前述の Words() と CreateSearchTextWords() をそのまま使っています。
var words = context.SqlCommandText.CreateSearchTextWords(
words: Words(searchText.SearchIndexes().Join(" ")),
searchText: searchText.SearchIndexes().Join(" "));
if (words?.Any() != true) return null;- サイトの
SearchTypeは参照されません。 一覧の検索を部分一致にしていても、横断検索は影響を受けません wordsが空ならnullを返して終わります。検索ボックスを空で送信しても全件は出ません
検索クエリは Items と Sites を SiteId で内部結合して発行されます。
return !countRecord
? Rds.SelectItems(
dataTableName: dataTableName,
column: column,
join: new SqlJoinCollection(
new SqlJoin(
tableBracket: "\"Sites\"",
joinType: SqlJoin.JoinTypes.Inner,
joinExpression: "\"Items\".\"SiteId\"=\"Sites\".\"SiteId\"")),
where: Rds.ItemsWhere().FullTextWhere(
context: context,
words: words,
siteIdList: siteIdList),
param: FullTextParam(words),
orderBy: orderBy,
offset: offset,
pageSize: pageSize)Sites と結合しているのは、権限判定とサイトごとの除外設定を見るためです。並び順は UpdatedTime の降順で固定されており、関連度によるスコアリングは行われません。新しく更新されたものが上に来ます。
横断検索から除外する 3 つの設定
FullTextWhere() に除外条件が集まっています。
return Rds.ItemsWhere()
.Add(
raw: "\"Items\".\"SiteId\" in ({0})".Params(siteIdList?.Join()),
_using: siteIdList?.Any() == true)
.FullTextWhere(
context: context,
words: words)
.Add(
raw: Def.Sql.CanRead,
_using: !context.HasPrivilege && !context.Publish)
.Add(raw: $"{context.Sqls.IsNull}(\"Sites\".\"DisableCrossSearch\",{context.Sqls.FalseString})={context.Sqls.FalseString}")
.Add(
raw: "\"Items\".\"ReferenceType\"<>'Sites'",
_using: Parameters.Search.DisableCrossSearchSites);| 設定 | 粒度 | 効果 |
|---|---|---|
Search.json の DisableCrossSearch | 全体 | 横断検索の画面自体がエラーになる |
サイトの設定の DisableCrossSearch | サイト単位 | そのサイトのレコードを結果から外す |
Search.json の DisableCrossSearchSites | 全体 | テーブル定義(Sites)自体を結果から外す |
- サイト単位の除外条件は
isnull(..., false) = falseです。列がNULLのサイト(設定したことがないサイト)は対象に含まれます Itemsテーブルにはレコードだけでなくテーブル定義自体も行として登録されているため、既定ではサイト名でテーブルそのものがヒットします。それを止めるのがDisableCrossSearchSitesです
権限フィルタ(CanRead.sql)
権限は Def.Sql.CanRead という SQL 断片で効いています。
"Sites"."TenantId"=@_T
and
(
exists
(
select *
from "Permissions"
where "Permissions"."ReferenceId" in
(
"InheritPermission","Items"."ReferenceId"
)
and "Permissions"."PermissionType" & 1=1
and
(
-- 部署・グループ・ユーザ・全体(-1) のいずれかで一致
)
)
)"Sites"."TenantId"=@ipT
and
(
exists
(
select *
from "Permissions"
where "Permissions"."ReferenceId" in
(
"InheritPermission","Items"."ReferenceId"
)
and "Permissions"."PermissionType" & 1=1
and
(
-- 部署・グループ・ユーザ・全体(-1) のいずれかで一致
)
)
)"Sites"."TenantId"=@ipT
and
(
exists
(
select *
from "Permissions"
where "Permissions"."ReferenceId" in
(
"InheritPermission","Items"."ReferenceId"
)
and "Permissions"."PermissionType" & 1=1
and
(
-- 部署・グループ・ユーザ・全体(-1) のいずれかで一致
)
)
)SQL Server は SQLServer/CanRead.sql、PostgreSQL は PostgreSQL/CanRead.sql、MySQL は MySQL/CanRead.sql が読み込まれます(App_Data/Definitions/Sqls/ の下の、Rds.json の Dbms と同じ名前のフォルダ。Def.cs、Directories.cs)。3 つの違いはテナント・組織・ユーザーのパラメータの接頭辞(SQL Server は @_T・@_D・@_U、PostgreSQL・MySQL は @ipT・@ipD・@ipU。Parameter.cs)と、省略した部分の Disabled の比較(SQL Server・PostgreSQL は 'false'、MySQL は 0)だけです。MySQL でも識別子が二重引用符のままなのは、プリザンターが SQL の実行前に sql_mode へ ansi_quotes を設定しているためです(MySqlCommandText.cs)。
- 先頭で
Sites.TenantIdを絞っているので、テナント越えは起きません Permissions.ReferenceIdをInheritPermissionとレコード ID の両方で見ているので、サイトの権限継承とレコード単位の権限の両方が効きますPermissionType & 1 = 1のビット演算で読み取り権限を判定しています- 部署・グループ・ユーザ・全体(
UserId = -1)のいずれかで一致すれば通ります
この条件が付くのは !context.HasPrivilege && !context.Publish のときだけです。HasPrivilege は Permissions.PrivilegedUsers(User.LoginId) で決まります(Context.cs#L215)。
WARNING
Permissions.json で特権ユーザに指定されているログイン ID は、権限フィルタを通らずに全件を検索できます。システム管理用の設計ですが、横断検索の結果もフィルタされない点は運用上知っておく必要があります。
結果表示は二段構え
検索結果にはタイトルと本文の抜粋が表示されますが、最初のクエリで取得しているのは ReferenceId・ReferenceType・Title だけです。本文は ResultContents() が別クエリで取得します。
private static DataSet ResultContents(
Context context, EnumerableRowCollection<DataRow> dataRows)
{
var statements = new List<SqlStatement>();
if (dataRows.Any(o => o.String("ReferenceType") == "Sites"))
{
statements.Add(Rds.SelectSites(
dataTableName: "Sites",
column: Rds.SitesColumn()
.ParentId(_as: "SiteId")
.SiteId(_as: "Id")
.Body()
.Items_Title(),
// ...
}
// Issues / Results / Wikis についても同様
return Repository.ExecuteDataSet(
context: context,
statements: statements.ToArray());
}図を読み込み中…
結果に含まれる参照タイプの分だけ SELECT 文を組み立て、ExecuteDataSet() で 1 回のラウンドトリップにまとめて投げています。
ハイライトの割り切り
キーワードのハイライトは RenderHighlighted() が Span<char> で本文を走査し、一致部分を <span class="highlight"> で囲む素朴な実装です(Indexes.cs#L371-L410)。
var foundIndex = remainingSpan.IndexOf(searchText.AsSpan(), StringComparison.Ordinal);StringComparison.Ordinalなので大文字小文字が区別されます。Sampleで検索したとき、本文のsampleはハイライトされません- 検索文字列をそのまま探しているため、かな変換や語分割は反映されず、スペース区切りで複数語を入れた場合はハイライトされません
- 描画・ハイライトの対象はタイトルと
Bodyだけです
検索自体はヒットしているのにハイライトが付かない(あるいはキーワードが画面のどこにも見えない)ことがあるのは、この実装によるものです。ハイライトは装飾であって検索条件とは別物です。
無限スクロールと PageSize
追加読み込みはクライアント側(searchevents.js)で、スクロールが結果の末尾に達したら $p.search() を呼ぶ作りです。
if ($('#SearchOffset').val() !== '-1') {
$p.search($('#Search').val(), false, $('#SearchOffset').val());
}サーバ側は、取得件数が PageSize に達していなければ #SearchOffset に -1 を返し、それで打ち止めになります(Indexes.cs#L261-L305)。
.Val(
"#SearchOffset",
(dataRows != null &&
dataRows.Any() &&
dataRows.Count() == Parameters.Search.PageSize
? offset + Parameters.Search.PageSize
: -1).ToString())PageSize(既定 20)を増やすとラウンドトリップは減りますが、ResultContents() の再クエリも大きくなるので、バランスで決めることになります。
リンク項目のドロップダウン検索
選択肢が多いリンク項目では、プルダウンではなく検索ダイアログが開きます。これは Column.UseSearch(public bool? UseSearch;、Column.cs#L80)で有効化します。
| アクション | 用途 |
|---|---|
SearchDropDown | ダイアログ内で候補を検索する |
SelectSearchDropDown | 検索結果から候補を選択する |
Items だけでなく Depts / Groups / Users / McpLogs にも同じ組が用意されており、選択肢の供給元ごとに実装されています。
UseSearch はインデックス生成にも影響する
Indexes.Create() は、インデックスを作る前にリンク項目の選択肢を解決しています。
ss.Links
?.Where(o => o.SiteId > 0)
.Select(o => ss.GetColumn(
context: context,
columnName: o.ColumnName))
.Where(column => column?.UseSearch == true)
.ForEach(column =>
ss.SetChoiceHash(
context: context,
columnName: column.ColumnName,
selectedValues: new List<string>
{
issueModel.PropertyValue(
context: context,
column: column)
}));リンク項目の FullTextType が 表示名 のとき、Items.FullText に入れるべきなのは ID ではなくリンク先のタイトルです。選択肢のハッシュを事前に埋めておかないと表示名を解決できないため、UseSearch == true の列に絞って解決しています。
INFO
逆に言うと、UseSearch が有効でないリンク項目では、インデックス生成時にこの解決処理が走りません。リンク項目を表示名で検索したい場合は、UseSearch の設定もあわせて確認してください。
MCP サーバの検索と 3 つの SearchTypes
プリザンターの MCP サーバの実装にも検索があります(MCP 全般は プリザンターの MCP を参照)。
public const string DefaultSearchTypeName = "ExactMatch";
public enum SearchTypes
{
ExactMatch,
PartialMatch,
ForwardMatch
}SearchTypes という名前の列挙体は 3 つあり、それぞれ別物です。
| 定義場所 | 値 | 用途 |
|---|---|---|
SiteSettings.SearchTypes | FullText / PartialMatch / MatchInFrontOfTitle / BroadMatchOfTitle | 一覧の検索ボックス |
Column.SearchTypes | PartialMatch / ExactMatch / ForwardMatch と各 Multiple | フィルタ列の絞り込み |
MCP.SearchUtilities.SearchTypes | ExactMatch / PartialMatch / ForwardMatch | MCP サーバ経由の検索 |
MCP 側は既定が ExactMatch で、文字列からのパースは Enum.TryParse(ignoreCase: true)に失敗すると例外にせず ExactMatch にフォールバックします(SearchUtilities.cs#L44-L55)。指定を間違えても気づきにくいので、意図した方式になっているか確認してください。
全文索引が作られる条件(CodeDefiner)
索引をいつ作るかも RDBMS ごとに違います。CodeDefiner の TablesConfigurator の実装です。
| RDBMS | 実行タイミング | 実行ユーザ | 存在判定 |
|---|---|---|---|
| SQL Server | 毎回実行 | sa(Def.SqlIoBySa()) | SQL 側の IF NOT EXISTS |
| PostgreSQL | IsCreatingDb が真のとき(DB 新規作成時)だけ | Admin(Def.SqlIoByAdmin()) | なし |
| MySQL | 存在しないとき | Admin(Def.SqlIoByAdmin()) | ExistsFullText.sql(Items の ftx だけを見る) |
- SQL Server: TablesConfigurator.cs#L81-L106
- PostgreSQL: TablesConfigurator.cs#L108-L126
- MySQL: TablesConfigurator.cs#L129-L146
private static void ConfigureFullTextIndexPostgreSql(ISqlObjectFactory factory)
{
try
{
if (!factory.SqlDefinitionSetting.IsCreatingDb)
{
return;
}
Def.SqlIoByAdmin(factory: factory)
.ExecuteNonQuery(
factory: factory,
dbTransaction: null,
dbConnection: null,
commandText: Def.Sql.CreateFullText);
}WARNING
PostgreSQL では、既存のデータベースに対して後から ftx インデックスが作られません。索引がない状態でも %> は動くため、遅いだけで気づきにくい状態になります。
索引の作成に失敗しても CodeDefiner は正常終了する
SQL Server 版の例外処理は、コンソールに 1 行書くだけで処理を続行します。
catch (Microsoft.Data.SqlClient.SqlException e)
{
Consoles.Write($"[{e.Number}] [{nameof(ConfigureFullTextIndexSqlServer)}]: {e}", Consoles.Types.Error);
}次のいずれかに当てはまると、黙って索引なしの状態になります。
- sa の接続文字列が未設定、または権限が足りない
- SQL Server に Full-Text Search 機能が入っていない
Language 'Japanese'の言語リソースが使えない
CodeDefiner は最後まで走って正常終了するので、気づけるのはコンソールの出力だけです。この状態でサイトの検索方式を「全文検索」にすると、実行時に contains() が失敗します。
Docker で SQL Server を動かす場合に Full-Text Search 機能を組み込む手順は 全文検索の環境構築 を参照してください。
添付ファイルの中身の検索(SearchDocuments)
「添付した Excel の中身まで検索できるか」の答えは RDBMS と添付ファイルの保存先次第です。条件を満たしていない環境は少なくありません。
SearchDocuments は条件式を 1 つ増やすだけのスイッチ
Search.json の SearchDocuments(既定 false)の参照箇所は、Indexes.cs の 2 つのメソッドだけです。
private static string FullTextWhere(
ISqlObjectFactory factory,
string name,
string itemsTableName = "Items",
bool negative = false)
{
var item = factory.SqlCommandText.CreateFullTextWhereItem(itemsTableName, name, negative);
var binary = factory.SqlCommandText.CreateFullTextWhereBinary(itemsTableName, name, negative);
return Parameters.Search.SearchDocuments
? $"({item} or {binary})"
: item;
}図を読み込み中…
WHERE 句が item から (item or binary) になるだけです。binary 側は Binaries テーブルを ReferenceId で結んだ相関サブクエリになります。
プリザンター自身はテキスト抽出をしていない
Items.FullText に入る添付ファイルの情報はファイル名か GUID だけで、Office 文書や PDF から本文テキストを取り出すコードはプリザンター本体には存在しません。添付ファイルの中身の検索は、バイナリ列(Binaries.Bin)に対する RDBMS 側の全文検索機能に丸投げされています。そのため RDBMS ごとの実力差がそのまま出ます。
また、この仕組みは Items.FullText とは別系統なので、「検索インデックスの再構築」を実行しても関係ありません。索引を育てるのは RDBMS 側の仕事です。
SQL Server は IFilter 前提の設計
SQL Server には、ドキュメントフィルタ(IFilter)でバイナリからテキストを抽出して全文検索する仕組みがあり、プリザンターはこれを前提に作られています。
CreateFullText.sql は Items だけでなく Binaries にも索引を張ります。
CREATE FULLTEXT INDEX ON [Binaries]
([Bin] TYPE COLUMN Extension Language 'Japanese')
KEY INDEX #PKBinaries#
ON ftx;CreateFullText.sql(SQL Server)
TYPE COLUMN Extensionは「この行のバイナリをどのフィルタで読むかはExtension列を見て決める」という指定Extension列にはPath.GetExtension(Name ?? FileName)の結果、つまり先頭のドット込みの".pdf"のような値が入る(Attachment.cs#L42-L73)。これはsys.fulltext_document_types.document_typeの表記と一致する- この DDL は
SearchDocumentsの値とは無関係に、CodeDefiner を実行するたびに索引の有無を確認して無ければ作られる
つまり既定状態は「索引は作るが、検索には使わない」という位置にあります。
既定 OFF なのに索引だけ作ることの副作用
SearchDocuments が false でも Binaries の全文索引は存在するため、添付をアップロードするたびに SQL Server 側で IFilter が呼ばれてテキスト抽出が走ります(誰も使わない索引のためのコスト)。対応するフィルタがない拡張子や壊れたファイルがあると、フルテキストクロールのエラーが SQL Server のログに溜まります。 逆に言えば、SearchDocuments を true にした瞬間に検索できるようになるのは、裏でずっと索引が育っていたからです。
RDBMS 別の実力差(CreateFullTextWhereBinary())
SQL Server は Bin 列に対する contains() です。
: $"(exists(select * from \"Binaries\" where \"Binaries\".\"ReferenceId\"=\"{itemsTableName}\".\"ReferenceId\" and contains(\"Bin\", @{paramName}#CommandCount#)))");SqlServerCommandText.cs#L91-L99
PostgreSQL は encode("Bin", 'escape') でバイナリを文字列化してからトライグラム比較しています。
: $"(exists(select * from \"Binaries\" where \"Binaries\".\"ReferenceId\"=\"{itemsTableName}\".\"ReferenceId\" and (encode(\"Bin\", 'escape') %> @{paramName}#CommandCount#)))");PostgreSqlCommandText.cs#L103-L111
| ファイル形式(PostgreSQL の場合) | 結果 |
|---|---|
.txt / .csv | バイト列がそのままテキストなので拾える可能性がある |
.docx / .xlsx / .pptx | ZIP 圧縮された XML なので原理的に拾えない |
.pdf | テキストが圧縮ストリームに入るのでほぼ拾えない |
| 画像・動画 | そもそもテキストを持たない |
しかも PostgreSQL の CreateFullText.sql が索引を張るのは Items だけなので、この条件は全件走査になり、添付ファイルが増えるほど重くなります。
MySQL は固定文字列 "0=1" を返します(MySqlCommandText.cs#L102-L107)。常に偽なので完全に未対応で、SearchDocuments を true にしても (item or 0=1) になるだけです。
| 生成される条件 | Binaries の索引 | 実質 | |
|---|---|---|---|
| SQL Server | contains("Bin", @p) | あり | 実用的に使える |
| PostgreSQL | encode("Bin", 'escape') %> @p | なし | 動くが全件走査、形式も限定的 |
| MySQL | "0=1" | — | 非対応 |
どの拡張子が読めるかは環境依存
SQL Server でも、TYPE COLUMN で選ばれる IFilter がその環境にインストールされている必要があります。
| 形式 | 拡張子 | 状況 |
|---|---|---|
| プレーンテキスト・HTML・XML | .txt .htm .html .xml | 標準で読める |
| Office 文書 | .docx .xlsx .pptx .doc .xls .ppt | SQL Server のバージョンや環境により差がある |
.pdf | 別途 IFilter の導入が必須(Adobe や Foxit が提供) | |
| 画像・圧縮 | .png .zip など | テキストを持たないため不可 |
環境によって差が出るので、自環境で確認してください(後述の「診断用 SQL」の「登録済みのドキュメントフィルタ」)。.pdf が出てこなければ PDF の中身は検索できません。IFilter を後から導入した場合は、登録と再クロールが必要です。
exec sp_fulltext_service 'load_os_resources', 1;
exec sp_fulltext_service 'restart_all_fdhosts';
alter fulltext index on [Binaries] start full population;最大の落とし穴:外部保存では Bin が NULL
SQL Server を使い、IFilter も入れて、SearchDocuments も true にしても、まったくヒットしないことがあります。原因は添付ファイルの保存先です。
var provider = BinaryStorageProviderFactory.Create(providerName);
// ...
var bin = provider != null ? default : GetBin(context);
statements.Add(Rds.UpdateOrInsertBinaries(
param: Rds.BinariesParam()
// ...
.Bin(bin, _using: provider == null)
.Bin(raw: "NULL", _using: provider != null)
.FileName(Name ?? FileName)
.Extension(Extension)
// ...
where: Rds.BinariesWhere().Guid(Guid)));provider が非 null、つまり外部保存のとき、Bin 列に明示的に NULL を書きに行っています。全文索引が張られているのは Binaries.Bin なので、索引には何も入りません。
保存先は BinaryUtilities.BinaryStorageProvider()(BinaryUtilities.cs#L773-L797)と BinaryStorage.IsStoreExternal()(IsLocal() || IsAzureBlob()、BinaryStorage.cs)で決まり、BinaryStorage.json の Provider が Local / LocalFolder / AzureBlob のいずれかなら外部保存になります(保存先の設定全般は 添付ファイルの保存先 を参照)。
図を読み込み中…
DANGER
BinaryStorage.json の Provider を Local にしている環境では、添付ファイルの中身の全文検索は動きません。SQL Server で IFilter が完璧に入っていても無反応です。
検索できる添付とできない添付が混在するパターン
| ケース | 何が起きるか |
|---|---|
途中で Rds から Local に変更 | NULL 化は新規追加の添付にしか走らないため、変更前の添付は検索でき、変更後はできない |
UseStorageSelect: true | 項目ごとに保存先を選べるので、同じサイト内で検索できる項目とできない項目が並ぶ |
AutoDataBaseOrLocalFolder | サイズで振り分けるため、大きいファイルほど検索できない |
TemporaryBinaryStorageProvider: "Rds" | 一旦 DB に入れてから外部へ移す経路。ただし条件付き |
- 保存先の変更:
Binの書き込み・NULL化はAttachment.SqlStatement()のif (Added == true)ブロックの中にあり、新しく追加された添付ファイルにしか適用されません。既存の行は触られないのでBinの値が残り、「古い添付は中身で検索できるが、新しい添付は検索できない」という一貫しない状態になります - サイズによる振り分け:
AutoDataBaseOrLocalFolderでは、size > column?.LimitSize * 1024L * 1024LのファイルがAzureBlobまたはLocalFolderに、それ以外がDataBaseに保存されます(BinaryUtilities.cs#L802-L818)。中身を検索したいことが多い大きい資料ほど検索できない、という逆説的な挙動です - 一時的に DB を経由する経路:
TemporaryBinaryStorageProviderがRdsのときは一旦 DB に入れてから外部へ移し、BinをNULLにします。この経路の条件にはcontext.Api != trueが入っており、API 経由のアップロードはこの経路を通りません(Attachment.cs#L163-L189)
Local モードの実体は拡張子なしの GUID
外部保存の実体は provider.Upload(objectName: $"Attachments/{Guid}", ...) で保存され、LocalFolderBinaryStorageProvider はそれを Directories.BinaryStorage() 配下に解決します。BinaryStorage.json の Path が空なら App_Data/BinaryStorage です(Directories.cs#L118-L124)。
したがって実体は App_Data/BinaryStorage/Attachments/{GUID} に、拡張子なしの GUID 名で置かれます。元のファイル名・拡張子・どのレコードの添付かという情報は Binaries テーブル側にしかなく、フォルダだけを見ても形式の判定もレコードへの紐付けもできません。Local モードに切り替えた時点で、SQL Server の IFilter は仕事のしようがなくなります。
ヒットしても「なぜヒットしたか」は画面に出ない
横断検索の結果表示はタイトルと Body しか描画せず、ハイライトもその 2 つだけが対象です(前述の「ハイライトの割り切り」)。添付ファイルの中身でヒットした場合は、画面上にキーワードがどこにも見えない検索結果が並びます。利用者から「検索がおかしい」と言われがちですが、仕様どおりの挙動です。
添付ファイルの中身を検索できる環境かのチェックリスト
| # | 確認項目 |
|---|---|
| 1 | RDBMS が SQL Server である(PostgreSQL は限定的、MySQL は非対応) |
| 2 | SQL Server に Full-Text Search 機能と、対象拡張子の IFilter が入っている |
| 3 | 添付の保存先がデータベースである(Bin が NULL でない) |
| 4 | Search.json の SearchDocuments を true にして再起動した |
3 番目でつまずく環境が多いはずです。UseStorageSelect や AutoDataBaseOrLocalFolder を使っている場合は、項目単位・サイズ単位での確認が必要です。確認用の SQL は「診断用 SQL」にまとめています。
本体を改修して、保存時にテキストを抽出して Items.FullText に載せる案は 添付ファイルの本文を本体で抽出して検索する にまとめています。
内部実装から導く検索の改善策
ここまでの内部実装を踏まえると、本体のソースコードを改変せずに、設定と拡張機能の使い方だけで打てる手がかなりあります。
考え方の軸:Items.FullText に載せる
RDBMS による挙動差も、保存先による「検索できる・できない」も、Binaries.Bin に対する検索の話です。Items.FullText に対する検索は保存先とは無関係に必ず動き、しかも Items.FullText はプリザンターが自分で組み立てているただの文字列です。
図を読み込み中…
「検索したいものは項目の値にする」 と考えれば、添付ファイル全文検索の限界のほとんどは迂回できます。
添付ファイルのファイル名を検索できるようにする
いちばん簡単で効果の高い手です。
| 手順 | 操作 |
|---|---|
| 1 | サイトの設定 → 項目 → 対象の添付ファイル項目の詳細を開く |
| 2 | 「全文検索」を 表示名 にする(GUID でも引きたければ 値と表示名) |
| 3 | サイトの設定から「検索インデックスの再構築」を実行する |
これは Items.FullText 側の話なので、保存先が Local でも AzureBlob でも、PostgreSQL でも MySQL でも動き、SearchDocuments の設定とも無関係です。中身までは検索できませんが、「あの見積書どこだっけ」というファイル名検索で足りる場面は多く、コストもかかりません。
添付ファイルの中身を検索できるようにする(外部ワーカーで抽出)
中身を検索したい場合は、抽出したテキストを Items.FullText 側に載せます。
図を読み込み中…
- 隠し項目(
DescriptionA〜DescriptionZのどれか)に抽出テキストを入れる - その項目の「全文検索」を
表示名にしておく - 更新 API が走ると
model.FullText()が呼ばれ、その項目の値も連結される - 以降は通常の検索でそのままヒットする。追加のインデックス処理は不要
抽出テキストは「ただの項目の値」なので、既存のインデックス生成の仕組みに自動的に乗り、「検索インデックスの再構築」でも連結されます。
WARNING
抽出テキスト用の項目は、編集画面に配置したうえで「非表示」にしてください。インデックスの対象は GetEditorColumnNames() が返す、編集画面に配置された項目だけです。配置から外すとこの方法は成立しません。
実ファイルの取り出し方
| 保存先 | 取り出し方 |
|---|---|
Local / LocalFolder | App_Data/BinaryStorage/Attachments/{Guid} を直接読む |
AzureBlob | 同じオブジェクト名で Blob から読む |
DataBase | /binaries/download?reference=items&guid={guid} を使う |
拡張子なしの GUID 名は、この用途ではむしろ好都合です。API のモデルには添付情報が含まれており(_BaseApiModel の AttachmentsHash、_BaseApiModel.cs#L21)、Attachments の要素には Guid / Name / Extension / Size / ContentType が入っています。API を 1 回呼べば、どのレコードのどの項目に、どんな名前と拡張子のファイルがどの GUID で保存されているかが分かるので、フォルダを走査する必要がありません。
認証の注意
Context は API キーを JSON のリクエストボディからしか読みません(Context.cs#L481-L493)。一方、添付のダウンロード(BinariesController.Download)はクエリ文字列付きの GET で JSON ボディがないため、API キーでは認証できずセッションが必要です(BinariesController.cs#L151-L164)。
| 保存先 | 認証の手当て |
|---|---|
Local / AzureBlob | メタデータ取得は API キー、ファイルは直接読む。セッション不要 |
DataBase | ダウンロードにセッションが必要。ログイン処理を実装する必要がある |
Local モードなら、ファイルはファイルシステムから直接読むほうが素直です。
サーバースクリプトだけでは完結できない
| 理由 | 内容 |
|---|---|
httpClient の戻り値が文字列だけ | Get() / Post() は string を返すので、バイナリを受け取れない(エンコードの過程で壊れる)。ServerScriptModelHttpClient.cs#L33-L41 |
$ps.file はテキストのみ | 用意されているのは ReadAllText だけで、バイナリ読み取りのメソッドがない。ServerScriptFile.cs#L24-L53 |
$ps.file のパス制限 | ルートは Script.json の ServerScriptFilePath 固定で、.. を含むパス・絶対パス・UNC は弾かれる。App_Data/BinaryStorage は読めない。ServerScriptFile.cs#L342-L365 |
| サイズ上限 | ServerScriptFileSizeMax の既定は 1(MB) |
抽出処理そのものは外部のプロセスに出す必要があります。 サーバースクリプトは、外部ワーカーを起動する側や書き戻し後の後処理を担う側として使うのが現実的です(httpClient と $ps.file の詳細は httpClient と外部 API 呼び出し、$ps.file と添付ファイル を参照)。
| 起動のトリガー | 特徴 |
|---|---|
| バックグラウンドサーバースクリプト | プリザンター側で完結。BackgroundJobs.json で制御(拡張サーバースクリプトとバックグラウンドサーバースクリプト) |
| 外部スケジューラ(タスクスケジューラなど) | 実装が単純。プリザンターに依存しない |
未抽出のレコードだけを処理するには、「抽出テキスト用の項目が空」かつ「添付ファイルがある」を条件にします。ビューのフィルタで抽出テキスト項目が空のものを絞ってから API で一覧を取得するのが素直です。
運用上の注意
| 論点 | 内容 |
|---|---|
Items.FullText の肥大化 | 1 レコード 1 文字列なので、抽出テキストは先頭 N 文字で切るなどの制限を入れる。最後の正規化(Distinct())で重複語は 1 つにまとめられるため、肥大化はある程度抑えられる |
| 更新者・更新日時が動く | 書き戻しはレコード更新なので UpdatedTime が変わる。専用ユーザで実行して区別できるようにする |
| 権限 | 隠し項目でも API からは読める。項目のアクセス制御を設定する |
| 再構築との関係 | 抽出テキストは項目の値なので、「検索インデックスの再構築」にもそのまま乗る |
一覧の検索を全文索引に乗せる
一覧の検索方式の既定は PartialMatch で、これは Items.FullText に対する前方ワイルドカードの LIKE なので全文索引が使われません(前述の「既定の PartialMatch は全文索引を使わない」を参照)。サイトの設定で検索方式を「全文検索」に変更すると索引に乗りますが、速くなる代わりにヒットの仕方が変わります。
| RDBMS | 変更後の挙動 |
|---|---|
| SQL Server | 語ごとの AND。索引が効いて高速になる |
| PostgreSQL | 検索文字列全体の類似度判定。語数が増えるとヒットしなくなる |
| MySQL | ngram_token_size(既定 2)より短い語が拾えなくなる |
「単語を足して絞り込む」使い方をしている利用者が多い PostgreSQL 環境では、切り替えによって不便になる可能性があります。切り替える前に、本番と同じ RDBMS の検証環境で実際の検索パターンを試してください。
目的別の設定一覧
| やりたいこと | 使う設定 | 内部実装上の根拠 |
|---|---|---|
| 特定サイトを横断検索から外す | サイトの設定の DisableCrossSearch | isnull("Sites"."DisableCrossSearch",false)=false |
| テーブル定義自体を結果から消す | Search.json の DisableCrossSearchSites | "Items"."ReferenceType"<>'Sites' |
| 横断検索を機能ごと止める | Search.json の DisableCrossSearch | Search() 冒頭でエラー画面を返す |
| 条件なしの全件表示を防ぐ | サイトの設定の AlwaysRequestSearchCondition | View.RequestSearchCondition() の判定式 |
| サイト名でのヒットを止める | サイトの設定の FullTextIncludeSiteTitle を無効化 | model.FullText() の先頭に連結される |
| パンくずでも引けるようにする | FullTextIncludeBreadcrumb を有効化 | 同上 |
| 送信メールを検索対象から外す | FullTextNumberOfMails を 0 にする | OutgoingMailsFullText() の if 条件 |
| 横断検索の 1 ページ件数を変える | Search.json の PageSize | #SearchOffset の打ち止め判定にも影響 |
| 大量投入時にインデックス生成を止める | Search.json の CreateIndexes を false | model.FullText() が null を返す |
| 検索できない項目を検索できるようにする | 編集画面に配置・「全文検索」の設定・インデックス再構築 | 前述の「更新したのに検索に引っかからない」3 つの原因 |
診断用 SQL
インデックスが古いレコードの件数
select count(*) from [Items]
where [SearchIndexCreatedTime] is null
or [SearchIndexCreatedTime] <> [UpdatedTime];select count(*) from "Items"
where "SearchIndexCreatedTime" is null
or "SearchIndexCreatedTime" <> "UpdatedTime";select count(*) from `Items`
where `SearchIndexCreatedTime` is null
or `SearchIndexCreatedTime` <> `UpdatedTime`;件数がゼロでないなら、バックグラウンド生成が追いついていないか、CreateIndexes が false になっているかのどちらかです。CreateSearchIndexLot の既定は 100 件なので、大量に投入した直後はしばらくゼロにならないのが正常な動きです。
全文索引の存在確認
-- SQL Server
select
t.name as table_name,
c.name as column_name
from sys.fulltext_index_columns fic
inner join sys.tables t on fic.object_id = t.object_id
inner join sys.columns c
on fic.object_id = c.object_id
and fic.column_id = c.column_id;-- PostgreSQL
select indexname, indexdef
from pg_indexes
where tablename = 'Items' and indexname = 'ftx';-- MySQL
select index_name, index_type
from information_schema.statistics
where table_name = 'Items' and index_name = 'ftx';PostgreSQL で結果が返らない場合は、IsCreatingDb の条件により索引が作られていない可能性があります。
SQL Server で Binaries の索引だけを確認するには、上のクエリに where t.name = 'Binaries' を付けます。
添付ファイルの保存先の内訳
添付ファイルの中身を検索できる環境かどうかは、多くの場合これだけで分かります。
select
case when [Bin] is null then 'External' else 'DataBase' end as [Storage],
count(*)
from [Binaries]
where [BinaryType] = 'Attachments'
group by case when [Bin] is null then 'External' else 'DataBase' end;select
case when "Bin" is null then 'External' else 'DataBase' end as "Storage",
count(*)
from "Binaries"
where "BinaryType" = 'Attachments'
group by case when "Bin" is null then 'External' else 'DataBase' end;select
case when `Bin` is null then 'External' else 'DataBase' end as `Storage`,
count(*)
from `Binaries`
where `BinaryType` = 'Attachments'
group by case when `Bin` is null then 'External' else 'DataBase' end;External しかないなら、その環境では添付の中身は検索できません。両方が出てきたら混在状態です。
登録済みのドキュメントフィルタ(SQL Server)
select document_type, path
from sys.fulltext_document_types
order by document_type;ここに対象の拡張子(.pdf など)が出てこなければ、その形式の中身は検索できません。
定義されているのに使われていないもの
ソースには、検索に関係しそうで実際には使われていない定義が残っています。
| 定義 | 内容 |
|---|---|
Libraries/Search/WordBreaker.cs | 697 行のクラス。文字種(Space / Symbol / Numeric / Lower / Upper / Hiragana / Katakana / Macron / Other / NotSet)で単語を切り出し、末尾の ー を落としたりアルファベットの部分文字列を生成したりする実装だが、どこからも参照されていない(WordBreaker.cs#L15-L26) |
Binaries.Body 列 | 列定義(Binaries_Body.json、nvarchar / MaxLength: -1)も BinaryModel のプロパティもあるが、添付ファイルの登録処理では誰も書き込んでいない |
現在の検索は RDBMS 側の分割機能に任せる設計なので、アプリケーション側で単語を切る必要がなくなったと考えられます。定義ファイルに Model_Utilities_SearchIndexes という名前が残っていることからも、かつて SearchIndexes テーブルに単語を格納する方式だった痕跡がうかがえます。
ソースコードの地図
| ファイル | 役割 |
|---|---|
Libraries/Search/Indexes.cs | 検索の中核 |
Libraries/Extensions/FullTextExtensions.cs | 型ごとの FullText 生成 |
Libraries/Extensions/SearchIndexExtensions.cs | 検索語の分割 |
Libraries/Settings/View.cs | 一覧の検索・列フィルタ |
Libraries/Settings/SiteSettings.cs | サイト単位の検索方式 |
Libraries/Settings/Column.cs | 項目単位の検索方式・UseSearch |
Libraries/DataTypes/Attachment.cs | 添付ファイルの Binaries への書き込み(Bin の NULL 化) |
Models/Binaries/BinaryUtilities.cs | 添付ファイルの保存先の決定 |
App_Data/Definitions/Sqls/SQLServer/CanRead.sql | 横断検索の権限フィルタ |
MCP/Utilities/SearchUtilities.cs | MCP サーバ経由の検索方式 |
Implem.PleasanterFrontend/.../searchevents.js | 横断検索の無限スクロール |
Rds/Implem.SqlServer/SqlServerCommandText.cs | SQL Server 用の条件式 |
Rds/Implem.PostgreSql/PostgreSqlCommandText.cs | PostgreSQL 用の条件式 |
Rds/Implem.MySql/MySqlCommandText.cs | MySQL 用の条件式 |
関連ページ
- 内部で動く SQL 文
- CodeDefiner
- Operations Tools と SQL デバッガー
- Fess で全文検索
- 全文検索の環境構築
- 添付ファイルの保存先
- プリザンターの MCP
- 検索を外部の検索エンジンに外出しする — 改修する場合の設計メモ
- 添付ファイルの本文を本体で抽出して検索する — 改修する場合の設計メモ
- MariaDB・TiDB で動かす — 全文索引の非互換と対応案