
エージェントに“検索”を渡す ― 全文(pg_trgm)とセマンティック(ベクトル)の使い分け
平原 典彦 — Tesseraを開発しているエンジニア。ヘッドレスCMSとAI連携に関心があります。
AIエージェントにCMSを触らせるようになると、早い段階でぶつかるのが「目的のエントリを、どう見つけさせるか」です。全件をコンテキストに読み込ませるのは非現実的(トークンが持たない)。だからエージェントには「検索」という道具を渡したい。
ただ、ひとくちに検索と言っても、欲しいものは2種類あります。
- キーワードで当てる:「
refundという語を含む記事」「slugがxxxのもの」——語がわかっているとき - 意味で当てる:「返品まわりのことが書いてある文書」——語は思い出せないが、意味で近いものが欲しいとき
Tesseraは、この2つを別々の仕組みで持ち、どちらもMCPツールとしてエージェントに開いています。search_entries(全文)と semantic_search(意味)です。この記事は、その2種類の検索の中身と使い分けの話です。
1. 全文検索:data::text ILIKE '%query%' を pg_trgm で索引化する
Tesseraのエントリは、値をJSONB(entries.data)に格納しています。コンテンツタイプごとにフィールド構成が違うので、「特定フィールドを指定する検索」より前に、まずレコード全体に対する素朴な部分一致が要ります。
search_entries の実体は、これだけです。
SELECT * FROM entries
WHERE workspace_id = $1
AND content_type_id = $2
AND data::text ILIKE '%query%' -- 大文字小文字を区別しない部分一致
ORDER BY created_at DESC
LIMIT $3 OFFSET $4;
JSONBを丸ごとテキスト化して、そこに対してILIKE '%query%'。フィールドを指定しない、素直な全体検索です。エージェントから見れば「このコンテンツタイプの中で、この語を含むエントリ」を1ツールで引ける。
問題は、このILIKE '%…%'が索引を使えないこと
%query% のように前方にワイルドカードが付く部分一致は、通常のB-tree索引が効きません。B-treeは「前から順に並んでいる」ことを利用するので、「途中に含まれる」を高速化できない。素直に書くと、エントリが増えるほど全件シーケンシャルスキャンになります。
そこで pg_trgm(トライグラム)のGIN索引を張りました。
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX entries_data_trgm_idx
ON entries USING gin ((data::text) gin_trgm_ops);
トライグラムは、文字列を3文字ずつの断片に分解して索引します(refund → ref, efu, fun, und…)。ILIKE '%…%'も内部的には3文字断片の一致に落とせるので、クエリの意味(部分一致)は一切変えないまま、索引だけが効くようになる。アプリ側のSQLはILIKEのままで、索引を差し替えただけです。
日本語で効く理由と、1つの注意点
ここが地味に効くところです。PostgreSQL標準の全文検索(tsvector)は、**言語ごとの語分割(トークナイザ)**に依存します。英語なら空白で切れますが、日本語は語の切れ目が自明でなく、標準では素直に扱えません。
pg_trgmは文字単位のトライグラムなので、語分割に依存しません。日本語のコンテンツでも「文字列として含まれるか」で引けます。語区切りを気にせず部分一致を索引化できる——これが、tsvectorではなくpg_trgmを選んだ理由です。
注意点も正直に書いておくと、3文字未満のクエリはトライグラムを構成できないので索引が効きません(2文字以下の検索はスキャンにフォールバックする)。ここは部分一致検索と割り切っています。
2. セマンティック検索:意味の近さで、必要なチャンクだけ返す
もう1つが semantic_search。語ではなく意味で探します。「返品」で検索して、「払い戻し」や「refund policy」と書かれた文書がヒットする、という世界です。
仕組みはRAGでよくある形ですが、ワークスペース限定に振り切っています(汎用ベクトルDBにはしない)。
- 取り込んだ資料をチャンクに分割し、埋め込みベクトルにして保存する
- 埋め込みモデルは Voyage
voyage-3.5(1024次元)、保存はpgvector、検索はコサイン類似度(vector_cosine_ops) - 検索時はクエリを同じモデルで埋め込み、近いチャンクを上位で返す
取り込み対象は2つです。
- アップロードしたテキスト / Markdownアセット
- セマンティック検索を有効にしたコンテンツタイプの text / richtext フィールド本文(コンテンツタイプ単位のオプトイン、既定はオフ)
返ってくるのは、チャンクごとのスコア(コサイン類似度)・タイトル・本文。エージェントは、全ファイルを読み込まずに「関連する箇所だけ」を受け取れます。これは差別化③のMCPと素直に噛み合います——エージェントのコンテキストに、必要な断片だけを載せるための検索です。
実装上の割り切りも2つ。
- 埋め込みプロバイダ(Voyage)が未設定なら、
semantic_searchはnot_configuredを返す(黙って空を返さない)。取り込み済みチャンクは埋め込み前ならpendingのまま保持される - 埋め込みのコストは可視化のため記録しますが、既定でAIクレジット(従量課金)を消費しません。読み取り体験を課金で削らない方針と揃えています
3. 使い分け:語で当てるか、意味で当てるか
2つは代替ではなく、担当が違います。
search_entries(全文・pg_trgm):語・ID・slug・型番など、当てにいく文字列がわかっているとき。決定論的で、埋め込み不要、全エントリに即座に効く。コンテンツタイプ単位semantic_search(意味・ベクトル):語は思い出せないが意味で近いものが欲しいとき。オプトインした資料・本文が対象で、関連チャンクをスコア付きで返す
たとえば「返品ポリシーの記述はどこ?」に対して、全文検索は「返品」という語が本文に無ければ空振りします。セマンティック検索なら「払い戻し」「returns」と書かれた箇所を拾える。逆に「slugがpricing-2026のエントリ」のような一点狙いは、全文検索の一撃が速い。
エージェントには、この2枚をそのまま渡しておく。探し方がわかっているときは全文、意味で手繰りたいときはセマンティック——エージェント自身が状況で選べます。
なぜ検索を「ツールとして」エージェントに渡すのか
根っこは、メディアの話(画像のバイト列はLLMに通さない)と同じ発想です。何をLLMのコンテキストに載せるかの線引きです。
エントリ全件や資料全文をコンテキストに流し込むのは、トークンを浪費し、他の作業を押し出します。かわりに「検索」という道具を渡せば、エージェントは必要なときに、必要な分だけ引ける。全文検索は「語を含むエントリ」を、セマンティック検索は「意味の近いチャンク」を、それぞれ最小限で返す。CMS側が検索を持っていることが、そのままエージェントの効率になります。
まとめ
- AIエージェントには全件を読ませるのではなく「検索」を道具として渡す。Tesseraは全文・意味の2種類をMCPで開いている
- 全文(
search_entries):data::text ILIKE '%query%'の部分一致。前方ワイルドカードでB-treeが効かないため、pg_trgmのGIN索引で索引化(意味は部分一致のまま不変)。文字トライグラムなので日本語も語分割に依存せず引ける。ただし3文字未満は索引が効かない - 意味(
semantic_search):Voyagevoyage-3.5(1024次元)+pgvector+コサイン類似度。対象は取り込んだテキスト/Markdownアセットと、オプトインしたコンテンツタイプ本文。関連チャンクをスコア付きで返し、AIクレジットは消費しない - 使い分けは「語がわかる→全文」「意味で手繰る→セマンティック」。どちらもエージェントが状況で選べる
- 本質は「何をLLMに通すか」。全件ではなく、検索で最小限の断片だけを渡す
エージェントにCMSを運用させるほど、「速く正しく見つける」ことが効いてきます。地味ですが、検索はエージェント時代のCMSの基礎体力だと思っています。