1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Databricks ai_prep_search (Beta) で PDF を RAG 用にチャンク化してみた

1
Last updated at Posted at 2026-04-30

🚨 まず最初に伝えたいこと

ai_prep_searchchunk_to_retrieve の冒頭が > で始まっていたら、それはテーブルが真っ二つに切られた死体です」 ⚰️

知らずに Vector Search に流すと、RAG の精度がジワジワ落ちます。本記事では Bronze → Silver → Vector Search 用 Silver への変換を ノートブックの実行順に追体験 できる形で解説します 🚀


📌 こんな方におすすめ

  • 🤖 Databricks で RAG パイプラインを組もうとしている人
  • 📄 PDF を ai_parse_document でパースしている人
  • 🪄 ai_prep_search (Beta) を本番投入しようか迷っている人
  • 🤯 「テーブルの途中で chunk が切れる問題」に頭を抱えている人

🎯 3 行まとめ

# 知見
1️⃣ ai_prep_search (Beta, 2026-04-08 リリース) のサポート options は 'version' のみchunk_overlapchunking_strategy存在しない 🚫
2️⃣ 大型の表は chunk 中間で物理切断される。「セマンティックチャンキング」というマーケ文言を信じすぎてはいけない ⚠️
3️⃣ 解決策は 「事前防御 (parse 直後に table を <tr> 単位分割) + 事後修復 (SQL Window 関数で断片を結合)」の二段構え

📚 そもそも ai_prep_search って何?

公式マニュアル(Microsoft Learn ja-jp 版 Last updated 2026-04-17):

ai_prep_search() 関数は、ai_parse_document の構造化された出力を RAG ベクター検索および情報取得システム用に最適化された形式に変換します。

📋 公式仕様サマリー

項目 内容
🔧 シグネチャ ai_prep_search(parsed VARIANT, [options MAP<STRING, STRING>]) RETURNS VARIANT
📦 必要 Runtime Databricks Runtime 18.2 以降 または Serverless 環境 3 以上
💻 対応言語 Python または SQL
⚙️ サポート options 'version' のみ
🚧 ステータス Beta (2026-04-08 リリース)

🚨 大前提:表データはベクトルDBに向かない(最初に押さえるべきこと)

これは初学者エンジニアに最初に伝えたいことです 🙏

# 制約 具体例
1️⃣ 意味の最小単位がセル単位ではなく 行×列の交差 「523」というセル単独では何の数値か復元できない 🤷
2️⃣ トークン長制限と表サイズの不整合 財務諸表は単独で 8,192 tokens を超える 📈
3️⃣ HTML タグが埋め込みベクトルのノイズになる <td> を含むクエリが本来無関係な表に過剰ヒット 🎯❌
4️⃣ チャンク境界で表が分断されると意味が消える 上半分は「列ヘッダー」、下半分は「裸の行」👻
5️⃣ 数値・単位・通貨の比較演算は守備範囲外 「売上が前年比10%増の部門」はベクトルでは答えられない ❌📊

💡 数値比較が要件にあるなら、本記事のアプローチではなく構造化テーブル併用パターンを採用すべきです。


🗺️ 全体パイプライン図

実装は次の流れで進めます。本記事はこの順番で実行順に解説します:
image.png

📁 Volume (PDF)
   ↓ READ_FILES (binaryFile)
🥉 Bronze: raw_pdfs                ← Step 3
   ↓ ai_parse_document (GA, v2.0)
🥈 Silver: parsed_documents         ← Step 4
   ↓ filter error_status
🥈 Silver clean: parsed_clean
   ↓ 🛡️ vicinity-aware + tag-integrity 二段検出
🛡️ Silver protected: parsed_protected ← Step 5
   ↓ 🪄 ai_prep_search (Beta)
🥈 Silver: chunks                   ← Step 6
   ↓ 🧩 SQL Window 関数で断片結合 + 上限ガード
🧩 Silver merged: chunks_merged ✨   ← Step 9 ← Vector Search に流す

🛤️ ノートブック実行順ガイド

✅ Step 0: 依存ライブラリのインストール 📦

BeautifulSouplxml をインストール。HTML タグ意識的分割で必須です。

%pip install --quiet beautifulsoup4 lxml
dbutils.library.restartPython()

💡 これがないと「タグの構造を理解した分割」ができません。


✅ Step 1: Widget 定義 🎛️

Notebook 上部に「変数を入力できるフォーム」を作って、コードを書き換えなくても catalog や schema を変更可能にします。

# 🎛️ ベース Widget
dbutils.widgets.text("catalog",      "sandbox",          "📦 1. Catalog")
dbutils.widgets.text("schema",       "default",          "📂 2. Schema")
dbutils.widgets.text("volume",       "pdf_demo",         "📁 3. Volume (PDF source)")
dbutils.widgets.text("image_volume", "pdf_demo_images",  "🖼️ 4. Image Volume")
 
# 検証する PDF セットを選択
dbutils.widgets.dropdown(
    "pdf_set",
    "all",
    ["academic", "sec_10k", "ipcc", "all"],
    "📚 5. PDF Set"
)
 
# 保護プリプロセスの動作パラメータ
dbutils.widgets.dropdown(
    "table_split_threshold",
    "threshold_balanced_3000",
    ["threshold_strict_2000", "threshold_balanced_3000", "threshold_loose_4000"],
    "🛡️ 6. Table Split Threshold"
)
設定 説明
pdf_set=all 4 PDFs (arXiv 2 本 + Apple 10-K + IPCC AR6) を一括 DL
threshold_balanced_3000 3000 chars 超のテーブルを分割 (推奨)

📌 派生 widget も自動登録

SQL セルから IDENTIFIER(:t_chunks) で参照できるよう、テーブルのフルパスも widget に登録します。

T_RAW            = f"{CATALOG}.{SCHEMA}.raw_pdfs"
T_PARSED         = f"{CATALOG}.{SCHEMA}.parsed_documents"
T_CLEAN          = f"{CATALOG}.{SCHEMA}.parsed_clean"
T_PROTECTED      = f"{CATALOG}.{SCHEMA}.parsed_protected"
T_CHUNKS         = f"{CATALOG}.{SCHEMA}.chunks"
T_CHUNKS_RAW     = f"{CATALOG}.{SCHEMA}.chunks_raw"
T_CHUNKS_MERGED  = f"{CATALOG}.{SCHEMA}.chunks_merged"
T_FIXED_CMP      = f"{CATALOG}.{SCHEMA}.chunks_fixed_size_baseline"

✅ Step 2: Volume 準備 + サンプル PDF 配置 📥

🏗️ スキーマと Volume を作成

CREATE SCHEMA IF NOT EXISTS IDENTIFIER(:catalog || '.' || :schema);
 
CREATE VOLUME IF NOT EXISTS IDENTIFIER(:catalog || '.' || :schema || '.' || :volume)
  COMMENT 'PDF source for ai_parse_document demo 🐰';
 
CREATE VOLUME IF NOT EXISTS IDENTIFIER(:catalog || '.' || :schema || '.' || :image_volume)
  COMMENT 'Rendered page images for multimodal RAG 🖼️';

📥 サンプル PDF をダウンロード

PDF ページ数 期待最大 table chars 特徴
📜 Attention Is All You Need (arXiv) 15 3,101 軽量、表小さめ
📜 BERT (arXiv) 16 1,195 さらに表小さい
💼 Apple 10-K FY2025 80 5,020 連結財務諸表が大型 💰
🌍 IPCC AR6 SYR Longer Report 115 5,111 気候データ表が複雑 🌡️
import os, urllib.request
 
PDF_CATALOG = {
    "academic": {
        "attention_is_all_you_need.pdf": {
            "url": "https://arxiv.org/pdf/1706.03762.pdf",
            ...
        },
        "bert.pdf": {...},
    },
    "sec_10k": {"apple_10k_fy2025.pdf": {...}},
    "ipcc":    {"ipcc_ar6_syr_longer_report.pdf": {...}},
}
 
# SEC は User-Agent 必須
req = urllib.request.Request(
    url,
    headers={"User-Agent": "Databricks-Notebook-Educational example@example.com"}
)

⚠️ ai_parse_document1 ドキュメントあたり最大 100MB / 500 ページ 制限があります。


✅ Step 3: Bronze - PDF をバイナリで読む 🥉

🔰 なぜ Bronze テーブルが必要?

ai_parse_document入力に BINARY 型の列を要求します。READ_FILES(..., format => 'binaryFile') を使うと、PDF を content (BINARY), path, _metadata 列をもつテーブルにできます。

CREATE OR REPLACE TABLE IDENTIFIER(:t_raw) AS
SELECT
  path,
  content,
  _metadata.file_name              AS file_name,
  _metadata.file_size              AS file_size,
  _metadata.file_modification_time AS file_modification_time
FROM READ_FILES(
  :volume_path,
  format          => 'binaryFile',
  fileNamePattern => '*.{pdf,PDF}'
)
-- 100MB 制限ガード
WHERE _metadata.file_size < 100000000;

✅ Step 4: Silver - ai_parse_document でパース 🥈✨

PDF (BINARY) を入力すると、VARIANT 型の構造化データを返します。

CREATE OR REPLACE TABLE IDENTIFIER(:t_parsed) AS
SELECT
  path,
  file_name,
  file_size,
  ai_parse_document(
    content,
    map(
      'version',                 '2.0',
      'imageOutputPath',         :image_volume_path,
      'descriptionElementTypes', '*'
    )
  ) AS parsed
FROM IDENTIFIER(:t_raw);

🔍 element の中身を確認

type の取りうる値:

  • text / title / section_header / table / figure / caption / page_header / page_footer / page_number / footnote

⚠️ 重要: 「table」要素の content は HTML 形式で返ってきます (ai_parse_document v2.0)。これがあとで ai_prep_searchHTML テーブル分断問題の原因になります 💀

🧹 成功 / 失敗で分割

-- 成功: error_status が NULL のもの
CREATE OR REPLACE TABLE IDENTIFIER(:t_clean) AS
SELECT path, file_name, parsed
FROM IDENTIFIER(:t_parsed)
WHERE try_cast(parsed:error_status AS STRING) IS NULL;
 
-- 失敗
CREATE OR REPLACE TABLE IDENTIFIER(:t_failed) AS
SELECT path, file_name, parsed:error_status AS error_status
FROM IDENTIFIER(:t_parsed)
WHERE try_cast(parsed:error_status AS STRING) IS NOT NULL;

✅ Step 5: 🛡️ HTML テーブル保護プリプロセス (parsed_protected)

ここがハマりポイント対策の 第 1 層 です 🎯

💡 なぜ事前防御が必要?

ai_prep_search に渡すに、大きな <table><tr> 単位で予防的に分割しておくと、後の chunk 中間切断を防げます。

🔰 アナロジー: 引っ越しの段ボール詰め 📦

概念 例え
chunk 段ボール (容量上限 ~ 7,000 chars)
element 荷物 (本、皿、グラス、絵画)
table 要素 割れ物 (絵画)

「絵画 (table) を入れる前に、絵画+周辺の荷物+空き容量を計算し、合計が箱の 85% を超えるなら、絵画を 1 枚目としてだけ入れて、残りは次の箱に回す」
画像1.png

これが vicinity-aware (隣接合算予測) の発想です 🧠

📐 設定値

CHUNK_TARGET_CHARS    = 3000   # この閾値超のテーブルを分割
CHUNK_HARD_MAX        = 5500   # 絶対上限
CHAR_LIMIT_OBSERVED   = 7000   # ai_prep_search の観測上限
VICINITY_TRIGGER_PCT  = 0.85   # 周辺合算がこの割合超なら分割
VICINITY_WINDOW       = 2      # 直前N + 直後N
VICINITY_EXCLUDE_TYPES = {"page_header", "page_footer", "page_number"}

🧠 段1-A: vicinity 圧力計算

def compute_vicinity_pressure(elements, target_idx, window=VICINITY_WINDOW):
    """
    target_idx 番目の element について、その前後 window 個 + 自身 の合算 char 数を返す。
    
    なぜ必要か:
      ai_prep_search は複数 elements を貪欲に合算してチャンクを作る (推測)。
      target が table 要素のとき、周辺との合算サイズが char 上限に近づくと
      合算チャンク内でテーブル中間切断が起きる。
    """
    if not elements or target_idx < 0 or target_idx >= len(elements):
        return 0
    start = max(0, target_idx - window)
    end   = min(len(elements), target_idx + window + 1)
    total = 0
    for i in range(start, end):
        el = elements[i]
        if isinstance(el, dict):
            etype = el.get("type", "")
            if i != target_idx and etype in VICINITY_EXCLUDE_TYPES:
                continue
            total += len(el.get("content") or "")
    return total

🪚 段1-B: HTML テーブル分割

def split_large_html_table(html_str, target=CHUNK_TARGET_CHARS, row_batch=15):
    """
    大きな <table> を「caption + thead + tbody の N 行ずつ」のサブテーブルに分割。
    各サブテーブルには caption と thead が複製され、それぞれが完結した HTML として valid。
    """
    if html_str is None or len(html_str) <= target:
        return [html_str] if html_str else []
    
    soup = BeautifulSoup(html_str, "lxml")
    table = soup.find("table")
    if table is None:
        return [html_str]
    
    caption = table.find("caption")
    thead   = table.find("thead")
    tbody   = table.find("tbody") or table
    rows    = tbody.find_all("tr", recursive=False)
    
    if not rows:
        # <tr> 構造を持たないフラット HTML テーブルへの fallback
        return _fallback_string_split(html_str, target)
    
    # 🌟 caption と thead を全分割片に複製する
    # → 各サブテーブル単独でも「何の表の何の列か」が分かる ✨
    chunks = []
    for i in range(0, len(rows), row_batch):
        batch_rows = rows[i:i + row_batch]
        parts = ['<table border="1">']
        if caption is not None: parts.append(str(caption))
        if thead is not None:   parts.append(str(thead))
        parts.append("<tbody>")
        parts.extend(str(tr) for tr in batch_rows)
        parts.append("</tbody></table>")
        chunks.append("".join(parts))
    return chunks

💡 「caption と thead を複製する」のなぜ?

これがないと、分割された 2 番目以降のサブテーブルには「Transformer (base) は 27.3」のような数値だけが並んで 「何の表の何の列か分からない」状態になります 👻

caption と thead を複製することで、各サブテーブル単独でも完結した意味を持ちます ✨ これは Anthropic Contextual Retrieval の思想とも整合的 🧠

🪄 Spark UDF として登録 → parsed_protected を作成

@F.udf(returnType=StringType())
def process_elements_udf(elements_json):
    return process_elements(elements_json)
 
(spark.table(T_CLEAN)
    .withColumn("elements_json_orig",
                F.expr("to_json(parsed:document:elements)"))
    .withColumn("elements_json_protected",
                process_elements_udf(F.col("elements_json_orig")))
    .withColumn("parsed_protected",
                F.expr("""
                  parse_json(
                    to_json(named_struct(
                      'document',     named_struct(
                        'pages',      parsed:document:pages,
                        'elements',   parse_json(elements_json_protected)
                      ),
                      'error_status', parsed:error_status,
                      'metadata',     parsed:metadata
                    ))
                  )
                """))
    .write.mode("overwrite").saveAsTable(T_PROTECTED))

✅ Step 6: ai_prep_search でチャンク化 🪄✂️

🎯 Q3 / Q4 への回答(最重要)

❓ Q3. セマンティックチャンキングを使うときの options は?

A. 公式マニュアルでサポートされる options のキーは 'version' のみセマンティックチャンキングはデフォルト動作として常に有効 です ✨

-- パターン1: options 省略形 (推奨)
SELECT ai_prep_search(parsed) AS result FROM parsed_clean;
 
-- パターン2: 出力スキーマバージョンを明示指定
SELECT ai_prep_search(parsed, map('version', '1.0')) AS result FROM parsed_clean;

❓ Q4. オーバーラップは設定できる?

A. 公式マニュアルに chunk_overlap 関連 option の記載は一切なし。実測でも overlap=0 の挙動です ✂️

必要なら SQL の LAG() Window 関数で後付け するのが現実解です。

-- 隣接 chunk の末尾 200 文字を現在 chunk の冒頭にコピー
SELECT
  COALESCE(
    SUBSTRING(LAG(chunk_retrieve) OVER (PARTITION BY file_name ORDER BY chunk_index), -200, 200),
    ''
  ) || chunk_retrieve AS chunk_with_overlap
FROM chunks_merged;

🪄 chunks テーブル作成

CREATE OR REPLACE TABLE IDENTIFIER(:t_chunks) AS
WITH prepped AS (
  SELECT path, file_name, ai_prep_search(parsed) AS result
  FROM IDENTIFIER(:t_protected)
),
exploded AS (
  SELECT
    chunk:chunk_id::STRING                AS chunk_id,
    path, file_name,
    chunk:chunk_position::INT             AS chunk_index,
    chunk:chunk_to_embed::STRING          AS chunk_text,        -- 埋め込み用 ✨
    chunk:chunk_to_retrieve::STRING       AS chunk_retrieve,    -- 検索結果用
    to_json(chunk:pages)                  AS chunk_pages_json,
    -- ⚠️ contents[].pages[] は page_id (document.pages[] は id なので注意!)
    try_cast(
      transform(
        try_cast(chunk:pages AS ARRAY<STRUCT<page_id: INT, image_uri: STRING>>),
        x -> x.page_id
      ) AS ARRAY<INT>
    )                                     AS page_ids_array,
    result:document:source_uri::STRING    AS source_uri,
    current_timestamp()                   AS created_at
  FROM prepped
  LATERAL VIEW posexplode(try_cast(result:document:contents AS ARRAY<VARIANT>)) AS chunk_idx, chunk
  WHERE chunk:chunk_to_embed IS NOT NULL
)
SELECT
  chunk_id, path, file_name, chunk_index, chunk_text, chunk_retrieve, chunk_pages_json,
  page_ids_array,
  array_join(transform(page_ids_array, x -> CAST(x AS STRING)), ',') AS page_ids,
  array_min(page_ids_array)                                          AS page_id_min,
  array_max(page_ids_array)                                          AS page_id_max,
  size(page_ids_array)                                               AS num_pages_in_chunk,
  source_uri, created_at
FROM exploded;

⚠️ スキーマの罠: page_id vs id

公式マニュアルをよく読むと:

場所 フィールド名
result:document.contents[].pages[] page_id
result:document.pages[] (トップレベル) id ⚠️

同じ pages 配列でもネスト位置で field 名が違います 🪤

📊 比較用に保護なし版も作成

-- 保護プリプロセスの効果を数値で比較するため、保護なしも保存
CREATE OR REPLACE TABLE IDENTIFIER(:t_chunks_raw) AS
WITH prepped AS (
  SELECT path, file_name, ai_prep_search(parsed) AS result
  FROM IDENTIFIER(:t_clean)   -- ← parsed_clean (保護なし)
)
... 同様 ...

✅ Step 7: チャンク前後の比較 (パース後 vs チャンク化後) 🔬

📊 量的比較

WITH parsed_stats AS (
  SELECT
    file_name,
    size(try_cast(parsed:document:elements AS ARRAY<VARIANT>)) AS num_elements,
    size(try_cast(parsed:document:pages    AS ARRAY<VARIANT>)) AS num_pages,
    aggregate(
      try_cast(parsed:document:elements AS ARRAY<VARIANT>),
      0L,
      (acc, x) -> acc + COALESCE(length(x:content::STRING), 0)
    ) AS total_raw_text_len
  FROM IDENTIFIER(:t_clean)
),
chunk_stats AS (
  SELECT
    file_name,
    COUNT(*)                            AS num_chunks,
    SUM(length(chunk_text))             AS total_embed_text_len,
    SUM(length(chunk_retrieve))         AS total_retrieve_text_len,
    ROUND(AVG(length(chunk_text)))      AS avg_embed_len,
    MAX(length(chunk_text))             AS max_embed_len,
    CONCAT('p.', MIN(page_id_min), '-p.', MAX(page_id_max)) AS chunk_page_range
  FROM IDENTIFIER(:t_chunks)
  GROUP BY file_name
)
SELECT
  p.file_name AS file,
  p.num_pages,
  p.num_elements,
  c.num_chunks,
  ROUND(CAST(p.num_elements AS DOUBLE) / c.num_chunks, 2) AS elements_per_chunk,
  c.avg_embed_len,
  c.chunk_page_range,
  ROUND(CAST(c.total_retrieve_text_len AS DOUBLE) / NULLIF(p.total_raw_text_len, 0), 2) AS retrieve_coverage,
  ROUND(CAST(c.total_embed_text_len AS DOUBLE) / NULLIF(c.total_retrieve_text_len, 0), 2) AS embed_expansion
FROM parsed_stats p LEFT JOIN chunk_stats c USING (file_name);

📖 読み方:

  • elements_per_chunk > 1.0 = 複数 element がまとめられて 1 chunk に(意味的結合の証左)
  • embed_expansion 1.10〜1.30 = メタプレフィックスが ~30% 分付与されている

✅ Step 8: チャンク戦略の検証 🔬

🔬 検証1: メタプレフィックスを観察

chunk_to_embed には公式テンプレートに従って固定プレフィックスが付きます:

The following passage represents a chunk of content from a document.
- 'Content' contains raw document text
...
 
Document Title: ...
Section Header: ...
Page Number: ...
 
Content:
...本文...

⚠️ Beta 期間の罠: hierchical typo

公式 docs では hierarchical information ですが、Beta 版実装では hierchical (h が 1 つ少ない typo) で返ってきます 🤦

# 両方のスペルを許容する regex
import re
re.search(r'hier(?:archical|chical)\s+information', chunk_to_embed)

🔬 検証2: チャンク間オーバーラップの有無

WITH paired AS (
  SELECT
    file_name,
    chunk_index AS curr_idx,
    substring(chunk_retrieve, GREATEST(length(chunk_retrieve) - 99, 1), 100) AS curr_tail,
    LEAD(substring(chunk_retrieve, 1, 100)) OVER (
      PARTITION BY file_name ORDER BY chunk_index
    ) AS next_head
  FROM IDENTIFIER(:t_chunks)
)
SELECT
  curr_tail, next_head,
  CASE
    WHEN next_head IS NULL THEN '(最終チャンク)'
    WHEN curr_tail = next_head THEN '🔄 完全一致 (overlap あり)'
    WHEN instr(curr_tail, substring(next_head, 1, 30)) > 0 THEN '⚠️ 部分一致'
    ELSE '✂️ 不連続 (overlap なし)'
  END AS overlap_status
FROM paired;

📊 実測結果: 全 187 ペアすべて 「✂️ 不連続 (overlap なし)」 ✂️

🔬 検証3: ページまたぎチャンクの数

SELECT
  file_name,
  num_pages_in_chunk,
  COUNT(*) AS num_chunks,
  ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (PARTITION BY file_name), 1) AS pct_in_file
FROM IDENTIFIER(:t_chunks)
GROUP BY file_name, num_pages_in_chunk
ORDER BY file_name, num_pages_in_chunk;

📊 実測: 70-92% がページまたぎ = ai_prep_search は物理ページ境界を意図的に無視して論理構造を優先 🧠

🔬 検証4: HTML テーブル中間分断検出

SELECT
  file_name, chunk_index, page_ids,
  CASE
    WHEN chunk_retrieve RLIKE '^\\s*>'                THEN '🚨 LIKELY-BROKEN: > で開始'
    WHEN chunk_retrieve RLIKE '^\\s*</[a-zA-Z]+>'     THEN '🚨 LIKELY-BROKEN: 閉じタグで開始'
    WHEN chunk_retrieve RLIKE '^\\s*<td[\\s>]'        THEN '⚠️  SUSPECT: <td> で開始'
    WHEN chunk_retrieve RLIKE '^\\s*<tr[\\s>]'        THEN '⚠️  SUSPECT: <tr> で開始'
    WHEN chunk_retrieve RLIKE '^\\s*[a-z]+>'          THEN '🚨 LIKELY-BROKEN: タグ末尾片で開始'
    WHEN chunk_retrieve RLIKE '^\\s*<table'           THEN '✅ OK: <table から開始'
    WHEN chunk_retrieve RLIKE '^\\s*[A-Za-z0-9]'      THEN '✅ OK: 通常テキスト'
    ELSE                                                   '❓ UNKNOWN'
  END AS table_split_status
FROM IDENTIFIER(:t_chunks);

💀 切断の例

chunk[N]   末尾: ... <table border="1"><thead><tr><th>Model</th>...
chunk[N+1] 冒頭: > 27.3</td></tr><tr><td>Transformer (big)...
                ↑↑↑
                この `>` 単独で始まる chunk = 切断の証拠 🚨

📊 検証4-α: 保護プリプロセスの定量効果

指標 ①保護なし ②保護あり 差分
total_chunks 191 191 ±0
orphan_close_table 10 6 -40%
unbalanced_table 39 36 -3
avg_retrieve_len 4,087 4,114 +27

💡 保護プリプロセス単体では一部の指標で改善幅が小さいですが、Step 9 の事後修復と組み合わせる ことで効果が爆発します 💥


✅ Step 9: 🧩 後処理: 切れた HTML テーブルを SQL で結合 (chunks_merged)

ここが本記事の真骨頂です 🎯

🔰 アナロジー: 破れた紙幣の修復 💵

image.png

概念 例え
<table[\s>] 出現 紙幣を破り始めた瞬間(balance を +1)
</table> 出現 紙幣を破り終えた瞬間(balance を -1)
running_balance > 0 まだ破れた紙幣の片を集めている最中
後処理結合 balance がゼロに戻るまで全片をテープでつなげる作業 🩹
上限ガード 紙幣束が分厚くなりすぎたら次の束に分ける 📦

📋 結合判定アルゴリズム (累積バランス方式)

各 chunk c[i] について:
  open_count[i]      := regexp_count(c[i], '<table[\s>]')   ※偽陽性除去
  close_count[i]     := regexp_count(c[i], '</table>')
  running_balance[i] := SUM(open_count[k] - close_count[k]) for k in 0..i
  needs_merge[i]     := (running_balance[i-1] > 0) OR (c[i] が断片で開始)
 
merge_group[i] := SUM(CASE WHEN needs_merge[i] THEN 0 ELSE 1 END) OVER (順序ウィンドウ)
 
上限ガード:
  break_flag[i] := (prev_running_chars[i] + self_chars[i] > MAX_MERGED_CHARS)
                    OR (prev_running_count[i] + 1 > MAX_MERGED_CHUNKS)
  sub_group_id := SUM(break_flag) OVER (...)

🚀 N chunks にまたがる超大型テーブルでも結合できる仕組み

例: Apple 10-K の Consolidated Financial Statements (5 chunks に切られたケース)

chunk open close running_balance prev_running_balance needs_merge
n 1 0 1 0 FALSE (グループ起点)
n+1 0 0 1 (引継ぎ) 1 ✅ TRUE → n と結合
n+2 0 0 1 1 ✅ TRUE → n と結合
n+3 0 0 1 1 ✅ TRUE → n と結合
n+4 0 1 0 1 ✅ TRUE → n と結合
n+5 0 0 0 0 FALSE (次のグループ起点)

5 chunks すべてが同じ merge_group に入り、1 チャンクに統合 ✨

🌟 実装 SQL のコア部分

WITH counted AS (
  SELECT
    chunk_id, file_name, chunk_index, chunk_retrieve,
    -- 偽陽性除去: <tablespoon, <tablename を <table と誤マッチさせない
    regexp_count(chunk_retrieve, '<table[\\s>]')   AS open_count,
    regexp_count(chunk_retrieve, '</table>')      AS close_count,
    (
      chunk_retrieve RLIKE '^\\s*>'
      OR chunk_retrieve RLIKE '^\\s*</[a-zA-Z]+>'
      OR chunk_retrieve RLIKE '^\\s*[a-z]+>'
    ) AS self_starts_with_fragment,
    length(chunk_retrieve) AS self_chars
  FROM chunks
),
balance AS (
  SELECT *,
    SUM(open_count - close_count) OVER (
      PARTITION BY file_name ORDER BY chunk_index
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_balance
  FROM counted
),
flagged AS (
  SELECT *,
    COALESCE(LAG(running_balance) OVER (PARTITION BY file_name ORDER BY chunk_index), 0)
      AS prev_running_balance,
    (COALESCE(LAG(running_balance) OVER (PARTITION BY file_name ORDER BY chunk_index), 0) > 0
     OR self_starts_with_fragment) AS needs_merge_with_prev
  FROM balance
),
prelim AS (
  SELECT *,
    SUM(CASE WHEN needs_merge_with_prev THEN 0 ELSE 1 END)
      OVER (PARTITION BY file_name ORDER BY chunk_index
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
      AS prelim_merge_group
  FROM flagged
)
-- 上限ガード適用 → 最終結合
SELECT
  file_name,
  MIN(chunk_index) AS chunk_index,
  -- chunk_index 昇順で連結 (決定的順序を保証)
  array_join(
    transform(
      array_sort(COLLECT_LIST(struct(chunk_index, chunk_retrieve))),
      s -> s.chunk_retrieve
    ),
    ''
  ) AS chunk_retrieve,
  COUNT(*) AS merged_chunk_count
FROM prelim
GROUP BY file_name, prelim_merge_group;

🛡️ 上限ガードの設定値

パラメータ デフォルト 理由
MAX_MERGED_CHARS 28,000 databricks-gte-large-en の 8,192 tokens ≈ 30,000 chars に約 7% の安全マージン
MAX_MERGED_CHUNKS 8 通常テーブルは 5 chunks 以内に収まる実測。安全マージン込み

🇯🇵 日本語ドキュメント中心の場合: 1 token ≈ 1.5-2 chars 程度になるため、MAX_MERGED_CHARS14,000-18,000 chars に下げるのが安全 🇯🇵

📊 3 段階比較: 保護なし vs 保護あり vs 保護+結合

段階 total unclosed_table orphan_close_table over_closed_table unbalanced_table
①保護なし 191 5 10 19 39
②保護あり 191 5 6 ⬇️ 20 36
③保護+結合 ✨ 166 1 ⬇️⬇️ 1 ⬇️⬇️ 12 16 ⬇️⬇️

📉 unbalanced_table が 39 → 16 に削減 (59%減) 🎉

💎 結合規模ヒストグラム

merge_size 件数 解釈
1 150 元から完結していた chunks
2 9 2 chunks にまたがるテーブルが結合
3 6 3 chunks にまたがる中規模テーブル
5 1 🌟 Apple 10-K の Consolidated Financial Statements!

apple_10k_fy2025.pdf の chunk_index=30 は 5 chunks → 1 chunk・20,880 chars に統合 されました 🚀

🛡️ 上限ガード発動状況

file total guard_triggered max_merge_size max_retrieve_len
apple_10k_fy2025.pdf 62 0 5 20,880
attention_is_all_you_need.pdf 12 0 2 7,342
bert.pdf 17 0 2 7,003
ipcc_ar6_syr_longer_report.pdf 75 0 3 8,560

<table[\s>] パターンによる偽陽性除去のおかげで、今回のデータセットでは guard 発動 0 件


🔬 おまけ: fixed-size との比較

ai_prep_search のセマンティックチャンキングが fixed-size より本当に良いのか確認:

FIXED_SIZE    = 1500
FIXED_OVERLAP = 200
 
@F.udf(returnType=ArrayType(StructType([
    StructField("idx",  IntegerType()),
    StructField("text", StringType()),
])))
def fixed_size_chunk(text):
    if not text:
        return []
    chunks, i, idx = [], 0, 0
    n = len(text)
    while i < n:
        chunks.append({"idx": idx, "text": text[i: i + FIXED_SIZE]})
        i += FIXED_SIZE - FIXED_OVERLAP
        idx += 1
    return chunks

🧐 解釈: 学術ベンチマーク (arXiv:2410.13070) は「semantic chunking の computational cost は consistent な性能向上で正当化されない」と結論。用途次第なので、自分のクエリ・データセットで A/B テストするのが正しいです 🎯


✅ Step 10: チャンク統計を可視化 📊

import plotly.express as px
 
pdf = (
    spark.table(T_CHUNKS)
        .filter("chunk_text IS NOT NULL AND length(chunk_text) > 0")
        .selectExpr("file_name", "length(chunk_text) AS chunk_len")
        .toPandas()
)
 
# ファイル別チャンク数
counts = pdf.groupby("file_name").size().reset_index(name="chunks")
fig1 = px.bar(counts, x="file_name", y="chunks",
              title="📚 ファイル別チャンク数 (semantic)")
fig1.show()
 
# チャンク長ヒストグラム
fig2 = px.histogram(pdf, x="chunk_len", nbins=30,
                    title="✂️ チャンク長分布 (文字数, semantic)")
fig2.show()

✅ Step 11: コスト確認 💰

SELECT
  usage_date,
  product_features.ai_functions.ai_function AS ai_function,
  SUM(usage_quantity) AS dbus
FROM system.billing.usage
WHERE billing_origin_product = 'AI_FUNCTIONS'
  AND product_features.ai_functions.ai_function IN ('AI_PARSE_DOCUMENT', 'AI_PREP_SEARCH')
  AND usage_date >= current_date() - INTERVAL 7 DAYS
GROUP BY usage_date, product_features.ai_functions.ai_function
ORDER BY usage_date DESC;

⚠️ 注意: ai_prep_searchBeta 期間中、system.billing.usage に記録されない可能性が高い です。GA 化時の有料化に備えて、ai_parse_document と同水準のバッファを月次予算に確保しておきましょう 💼


🎁 まとめ: 本番投入チェックリスト

📐 パイプライン全体

ステップ 関数 / 処理 結果テーブル
🥉 Bronze READ_FILES(binaryFile) raw_pdfs
🥈 Silver ai_parse_document (GA) parsed_documents / parsed_clean
🛡️ Silver HTML テーブル保護プリプロセス parsed_protected
🥈 Silver ai_prep_search (Beta) chunks
🧩 Silver 後処理結合 (累積バランス + 上限ガード) chunks_merged

✅ 投入前

  • 想定クエリが「文書要約」中心か「数値比較」中心かを判定 🤔
  • 数値比較中心なら本記事ではなく構造化テーブル併用パターンを採用
  • 公式マニュアルを再確認 (Beta なので変更リスクあり)
  • Runtime 18.2+ または Serverless v3+ であることを確認
  • DESCRIBE FUNCTION EXTENDED ai_prep_search で関数の存在を確認

✅ 運用時モニタリング

指標 閾値 対処
🚨 unclosed_table 件数 急増時 入力 PDF 点検
🚨 orphan_close_table 件数 急増時 同上
🛡️ guard_triggered_count 急増時 ネスト table の有無確認
📊 integrity_score < 0.7 件数 週次集計 element 単位で逆突合
💰 system.billing.usage (AI_PARSE_DOCUMENT) 週次確認 コスト監視

🔮 まとめのまとめ

✨ 学び 詳細
🎯 公式仕様を最初に正確に把握する サポート options が 'version' だけだと知らずに chunk_overlap を期待すると大幅な手戻り発生
🪤 Beta 関数のマーケ文言を信じすぎない 「セマンティックチャンキング」と書いてあってもテーブル不分断は保証されない
🛡️ 二段防御でケアする 事前防御 (parse 直後) + 事後修復 (SQL Window) の組み合わせ
💵 上限ガード必須 暴走的結合を予防する保険として常時稼働
📊 実測値で語る 推測ではなく regexp_count + Window 関数で定量モニタリング

📚 参考リンク

URL 内容
🌟 ai_prep_search 公式 (Microsoft Learn ja-jp) 本記事の一次情報 (Last updated 2026-04-17)
📖 ai_parse_document 公式 パース関数の仕様
📰 April 2026 リリースノート ai_prep_search Beta 公開 (4/8/2026)
🧠 Anthropic Contextual Retrieval 各 chunk に context prefix を加える手法
🛠️ Unstructured.io chunking spec "Table element is always isolated"
🔗 LangChain HTMLSemanticPreservingSplitter テーブル保持型 splitter
📊 Databricks Vector Search 品質ガイド parsing 品質が retrieval 精度に与える影響

🙏 最後に

この記事が 同じ罠で頭を抱える誰か の役に立てば嬉しいです 🥲

ベータ機能を実測値ベースで評価する文化が広まってほしい...🙏

質問・補足・「俺の環境ではこうだった」のフィードバック、コメント欄でお待ちしています 💬

いいねとストックよろしくお願いします! 🎉🎉🎉


🐰 追記 (2026-04-30): 本記事は ai_prep_search Beta 期間中の検証です。GA 化時に options 追加・スキーマ変更が起こる可能性があります。本番投入前に必ず公式 docs を再確認してください 📌

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?