
Cortex Search で拾えないスライドを中扉検出の5条件ルールで拾えるようにした
こんにちは、データ事業本部のまっきーです。
PPTX の会議資料を Snowflake Cortex Search で意味検索できるようにする検証をしていて、「ページ単位でチャンクを切ると、本文にキーワードが書かれていないスライドが検索で拾えない」という壁に当たりました。中扉(セクション区切りスライド)をルールベースで検出し、そのタイトルを配下のチャンクに埋め込むことで、Snowflake の SQL だけでこの問題を解決できました。この記事ではその方法と、検証の中で見つけたドキュメント未記載の挙動を紹介します。
課題 ー 「中扉の下のスライド」が検索で拾えない
ビジネスの報告資料や会議資料の PPTX には、次のような構造がよくあります。
- 「製品Aの活動状況」のような中扉(セクション区切りスライド)が挟まる。
- 中扉の後に続くスライドは表やグラフが中心で、本文にトピック名(製品名など)が書かれていない。
この構造の資料をスライド単位でチャンク化して Cortex Search に投入すると、「製品Aの3月の状況は?」のような質問に対して肝心のスライドを正しく特定できません。スライド本文のどこにも「製品A」と書かれていないため、順位が沈んだり、同じ「実績サマリー」見出しを持つ別セクションのスライドを上位に取り違えたりします。人間は「中扉からの流れ」で文脈を補って読んでいますが、チャンクは1スライドで完結しているのでその文脈を持っていません。
Cortex Search は、Snowflake 内のテキストデータに対する意味検索(セマンティック検索)のマネージドサービスです。埋め込みモデルの管理やインデックスの更新は Snowflake 側が面倒を見てくれます。
そこで、中扉を検出してセクション名を配下の各チャンクに埋め込むアプローチを取りました。処理はすべて Snowflake 内の SQL で完結します。
やったこと
AI_PARSE_DOCUMENT(LAYOUT モード +page_split)で PPTX をスライド単位の Markdown にパースする。- 各スライドが「中扉かどうか」をルールベースで判定する。
- 中扉のタイトルを次の中扉まで引き回し、各チャンクの先頭に「セクション > 見出し」の形で付与する。
- セクション付きチャンクをソースに Cortex Search Service を作る。
順に見ていきます。
1. AI_PARSE_DOCUMENT ー PPTX をスライド単位の Markdown にする
AI_PARSE_DOCUMENT の LAYOUT モードに page_split オプションを付けると、PPTX を1スライド1要素の pages 配列として返してくれます。各要素の content にはスライドの中身が Markdown で入ります。
SELECT AI_PARSE_DOCUMENT(
TO_FILE('@DOCS_STAGE', relative_path),
{'mode': 'LAYOUT', 'page_split': TRUE}
) AS parsed;
パース結果はテーブル(ここでは DOC_PARSED)に保存しておき、後段はすべてこのテーブルを起点にします。AI 関数を毎回呼び直すとページ数に応じた課金が繰り返し発生するため、「パースは1回、加工は何度でも」の構成にするのがポイントです。
2. 中扉判定 ー LLM に聞かず、5条件のルールで判定する
中扉スライドには「本文が短い」「画像がない」「タイトルだけがポツンとある」という共通の特徴があります。私のケースでは、次の5条件の組み合わせで実用的な精度が出ました。
SET divider_max_chars = 60; -- 中扉判定の文字数閾値(コーパスに合わせて要調整)
-- 各スライドの中扉判定(page_content はパース結果の Markdown)
(
LENGTH(page_content) < $divider_max_chars -- ①本文が短い
AND REGEXP_COUNT(page_content, '!\\[img-') = 0 -- ②画像なし
AND LENGTH(TRIM(
REGEXP_REPLACE(page_content, '[0-9#[:space:]]', '')
)) >= 2 -- ③数字・記号だけのページを除外
AND NOT CONTAINS(page_content, '\n- ') -- ④箇条書き(目次・Agenda)を除外
AND (
page_index > 0 -- ⑤2ページ目以降、または
OR REGEXP_LIKE(TRIM(page_content), '^#[^\n]{1,50}$') -- 見出し1行のみの1ページ目
)
) AS is_divider
各条件の意図は次のとおりです。
- ①短い: 中扉はタイトル程度しか書かれていません。閾値の 60 字は手元のコーパスの実測から決めた値なので、扱う資料に合わせて調整してください。
- ②画像なし: グラフや表があるスライドは本文ページです。LAYOUT モードは画像を
![img-N.jpeg]形式で出力するので、その有無で判定できます。 - ③数字・記号だけを除外: ページ番号だけのスライドや白紙に近いスライドが①をすり抜けるのを防ぎます。
- ④箇条書きを除外: 目次や Agenda のスライドは短くても中扉ではありません。
- ⑤表紙の除外: 1ページ目は通常タイトルスライド(表紙)なので除外します。ただし「見出し1行だけの1ページ目」に限り中扉として扱います。表紙を置かず、いきなりセクション見出しから始まる資料があったための例外です。
LLM に「このスライドは中扉ですか?」と聞く方法も考えられますが、スライド数×資料数だけ AI 関数の課金が発生し、同じスライドでも判定結果が揺れることがあります。一方で中扉には「本文が短い・画像がない」という見た目の特徴が資料を問わず安定してあるため、AI に聞かなくても上の5条件だけで判定できました。精度が変わらないなら、課金がなく結果も毎回同じルールベースの方が有利です。
3. セクション名の引き回し ー LAST_VALUE の IGNORE NULLS が効く
「このスライドはどのセクションに属するか」は、スライドを先頭から順に見ていき、「最後に通過した中扉のタイトル」を覚えておいて、次の中扉が現れるまで各スライドに付け続けることで決まります。スライド3が中扉「製品Aの活動状況」なら、スライド4・5にも「製品Aの活動状況」が付く。スライド7で中扉「製品Bの活動状況」が現れたら、そこから先は「製品B」に切り替わる。人間が資料をめくりながら「今は製品Aの章を読んでいる」と把握するのと同じ動きです。
これを SQL で書くと、ウィンドウ関数の LAST_VALUE になります。中扉でないスライドの divider_title は NULL なので、IGNORE NULLS を付けることで「現在のスライドまでで最後に現れた NULL でない値 = 直近の中扉タイトル」が取れます。
LAST_VALUE(divider_title) IGNORE NULLS OVER (
PARTITION BY file_id
ORDER BY page_index
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS section_title
ここで注意したいのが ROWS BETWEEN 句の省略です。省略するとウィンドウの既定枠がパーティション全体になり、全スライドに「最後の中扉」のタイトルが付いてしまいます。「現在行まで」を明示するのが必須です。
4. チャンク化 ー 「セクション > 見出し + 本文」形式で埋め込む
チャンク本文は次の形式で組み立てます。
製品Aの活動状況 > 3月の実績サマリー
(スライド本文の Markdown)
チャンク分割には SPLIT_TEXT_RECURSIVE_CHARACTER を使い、スライド内で完結させます。各チャンクには出典表示用のスライド番号(pages 配列の位置 + 1)を持たせておき、検索結果から「どの資料の何枚目か」を辿れるようにします。スライドをまたぐチャンクを作らないのは、この出典スライド番号が一意に決まらなくなるためです。また、中扉スライド自身はチャンク化から除外します。RAG では、検索でヒットしたチャンクが LLM に渡されて回答の根拠になります。中扉はタイトル1行だけで回答の材料になる情報を持たないため、ヒットしても LLM は答えを組み立てられず、本文チャンクが入るはずだった検索結果の枠を1つ潰すだけになります。「情報量の乏しいチャンクはインデックスに入れない」という RAG の一般的な考え方に沿った除外です。タイトルの情報自体は配下チャンクのセクション名として複製済みなので、除外しても失われるものはありません。
SELECT
-- セクション・見出しは有るものだけを「 > 」で繋ぎ、本文と空行で結合する
ARRAY_TO_STRING(
ARRAY_CONSTRUCT_COMPACT(
NULLIF(
ARRAY_TO_STRING(
ARRAY_CONSTRUCT_COMPACT(section_title, page_heading), ' > '
), ''
),
c.VALUE::STRING
), '\n\n'
) AS chunk_text
FROM sectioned_pages,
LATERAL FLATTEN(
INPUT => SNOWFLAKE.CORTEX.SPLIT_TEXT_RECURSIVE_CHARACTER(
page_content, 'markdown', 1000, 200
)
) AS c
WHERE NOT is_divider;
この形式の利点は、中扉がある資料とない資料を事前に仕分けなくていいことです。中扉がない資料では section_title が NULL のままになり、ARRAY_CONSTRUCT_COMPACT が NULL を自動で落とすので、チャンクは自然に「見出し + 本文」だけの形になります。「この資料は中扉あり・この資料はなし」と人間が分類したり、資料のタイプごとに別の SQL を用意したりする必要がなく、1本の SQL を全資料に流すだけで済みます。

5. Cortex Search Service を作る
セクション付きチャンクのテーブルをソースに Cortex Search Service を作ります。
CREATE CORTEX SEARCH SERVICE SEARCH_DOCUMENTS
ON chunk_text
ATTRIBUTES file_name
WAREHOUSE = <your_warehouse>
TARGET_LAG = '1 hour'
EMBEDDING_MODEL = 'voyage-multilingual-2'
AS
SELECT chunk_text, section_title, file_name, page_number
FROM DOC_CHUNKS_SECTION;
日本語資料なので EMBEDDING_MODEL は多言語対応の voyage-multilingual-2 を指定しています(作成後に変更できないので注意してください)。
効果 ー 取り違えが解消し、正解スライドが1位に
冒頭の例と同じ構造のサンプル資料(全10枚・中扉2枚)を用意し、プレーン版(ページ単位チャンクのみ)とセクション版の2本の Cortex Search Service を作って、同じ質問で比較しました。
質問は「製品Aの3月の実績は?」。正解は製品Aセクション配下の「3月の実績サマリー」スライドですが、本文に「製品A」という語はありません。さらに紛らわしいことに、製品Bセクションにも同名の「3月の実績サマリー」スライドがあります。
以下は SEARCH_PREVIEW で実際に返ってきた検索結果です(上位3件)。
| 順位 | プレーン版(before) | セクション版(after) |
|---|---|---|
| 1位 | 製品Bの実績サマリー(取り違え) | 製品Aの実績サマリー(正解) |
| 2位 | 製品Aの実績サマリー(正解) | 製品Bの実績サマリー |
| 3位 | 中扉「製品 A の活動状況」(本文なし) | 製品Aの重点顧客への訪問状況 |
プレーン版は、本文の構造がよく似た製品Bのスライドを1位に取り違えました。この結果をそのまま RAG に渡すと、製品Bの数字を根拠に「製品Aの実績」を答えてしまいます。3位に入った中扉チャンクは本文を持たず、検索結果の枠を1つ無駄にしています。
セクション版では正解が1位に浮上し、正解チャンクのリランカースコア(@scores.reranker_score)は -5.0 → +2.2 に改善しました。チャンク先頭に付けた「製品 A の活動状況 >」が、クエリ内の「製品A」と対応した結果です。中扉チャンクは除外済みなので、結果の枠を無駄にすることもありません。なお、スコアや順位は資料の内容や埋め込みモデルの更新によって変わる可能性があります。


ハマったところ ー 非表示スライドでスライド番号がずれる
手順4で、各チャンクに出典表示用のスライド番号を持たせました。検索の動作確認で出典を実物の資料と突き合わせていたところ、一部の資料でこの番号が実物の PPTX と合わないことに気づきました。調査したところ原因は非表示スライドで、公式ドキュメントに記載のない挙動だったので共有します。
効果検証で使ったサンプル資料は、全10枚のうち6枚目と9枚目を非表示にしてあります。これを AI_PARSE_DOCUMENT(LAYOUT + page_split)でパースして確認したところ、次の挙動になりました。

- 非表示スライドはパース結果からスキップされ、10枚の資料に対して8ページしか返らない。
- 非表示スライドの直後から番号がずれる。実物の7枚目が
pages配列では6番目に入り、9枚目(非表示)を挟んだ10枚目は8番目に入る。 pages[].indexは配列の位置と完全に一致する連番で、欠番は現れない。
| 実物のスライド | パース結果のページ位置 |
|---|---|
| 1〜5枚目 | 1〜5(一致) |
| 6枚目(非表示) | なし(スキップ) |
| 7〜8枚目 | 6〜7(1ずれ) |
| 9枚目(非表示) | なし(スキップ) |
| 10枚目 | 8(2ずれ) |

つまり、パース結果から「元のパワーポイントで何枚目だったか」を復元する材料がありません。私が確認した範囲では SQL 側で補正する方法はなく、「パース結果のスライド番号は表示スライドだけの通し番号」という制約として受け入れる判断をしました。非表示スライドの本文が検索対象に入らないこと自体は、「見せない意図のスライド」と考えれば妥当な挙動といえます。
なお、公式ドキュメント(AI_PARSE_DOCUMENT)では pages[].index は「0始まりのページ番号」とだけ説明されており、非表示スライドの扱いには触れられていません。この挙動は将来変わる可能性がある点にご注意ください。
まとめ
- ページ単位チャンクでは、中扉の下の「本文にキーワードがないスライド」を意味検索で正しく特定できません。今回の検証では、別セクションの類似スライドを1位に取り違えました。
- 中扉は「短い・画像なし・箇条書きなし」などのルールベース判定で実用的に検出できます。
- 中扉タイトルを
LAST_VALUE ... IGNORE NULLSで引き回し、「セクション > 見出し + 本文」形式でチャンク化することで、文脈を保った検索ができるようになります。 - 中扉がない資料にも型分岐なしで同じ SQL が適用できます。
- 非表示スライドを含む PPTX では、パース結果のページ番号が実物のスライド番号とずれる点に注意が必要です。
PPTX のセクション構造は、パース結果を眺めているだけでは活かしどころが見えにくいのですが、「人間が資料をどう読んでいるか」に寄せてチャンクを設計すると検索の景色が変わりました。PPTX 資料の検索精度に悩んでいる方の参考になれば幸いです。







