Crawl4AIとPython:LLMおよびRAGシステム向けの高精度クリーンMarkdown抽出

AI tutorial - IT technology blog
AI tutorial - IT technology blog

現実の課題:LLMパイプラインにおけるトークン肥大化とノイズ

未加工のWebデータは、検索精度を瞬く間に低下させます。昨年、私たちのチームは14,000ページにおよぶ開発者向けドキュメントを検索する社内アシスタントを構築しました。しかし稼働からわずか1週間で、ベクターストレージとエンベディングの利用料が140%も急増してしまったのです。さらに深刻だったのは検索精度の著しい低下でした。エンジニアがJWT refresh rotationを検索しても、実際のエンドポイントのコードではなく、Cookieバナーやヘッダーのドロップダウン、軽量化(minify)されたアクセス解析スクリプトばかりが含まれたチャンクがヒットしてしまう状態でした。

そもそもHTMLはWebブラウザ向けに設計されたものであり、ニューラルネットワーク向けではありません。従来型のスクレイパーは生のDOMツリーを取得するため、後続のパイプラインはインラインSVGや幾重にもネストされた<div>ラッパータグ、トラッキングピクセルなどの処理に追われることになります。このような生の構造をtext-embedding-3-smallなどのエンベディングモデルに渡してしまうと、貴重なトークン予算が構造上のノイズに浪費されます。さらに悪いことに、セマンティックチャンカーが自然な概念の境界ではなく、レイアウト用のタグで文章を分断してしまうのです。

従来型スクレイパーがコンテキストウィンドウで苦戦する理由

レガシーなスクレイパーをLLMのデータ取り込み(インジェスチョン)に適用する際、3つの構造的な壁が立ちはだかります。

  • 低いS/N比(シグナル・ノイズ比):現代のWebサイトにおいて、実際の本文テキストは生のHTMLペイロード全体の18%未満であることが珍しくありません。残りの82%はCSSクラス、スクリプトバンドル、ナビゲーションの定型コードで占められています。
  • 構造の平坦化:正規表現やBeautifulSoup.get_text()を使った単純なタグ除去を行うと、見出しやテーブル、コードブロックが区切りのない1つのテキストの塊へと平坦化されてしまいます。その結果、技術ドキュメントの意味を支える階層構造がベクトルデータベース上で失われてしまいます。
  • クライアントサイドのハイドレーション:Next.js、React、Vueなどで構築されたシングルページアプリケーション(SPA)は非同期にハイドレーションを行います。requestsやhttpxのような静的HTTPクライアントでは空のプレースホルダータグしか取得できず、開発者はリソース消費の激しい複雑なヘッドレスブラウザ環境の運用を余儀なくされます。

スクレイピングアーキテクチャの比較

持続可能なデータ取り込み基盤を見極めるため、500件のドキュメントサイトと動的ブログを対象に4つのスクレイピング戦略を検証しました。

技術スタック JSレンダリング Markdown再現性 リソースフットプリント
BeautifulSoup + html2text 不可 低(テーブルやコードブロックの崩れが頻発) 1プロセスあたり約30MB RAM。高速だがSPAには非対応
Selenium / Puppeteer スクリプト 対応 中(独自のASTパースルールの保守が必要) ワーカーあたり350〜500MB RAM。メンテナンス負荷が高い
ホスト型抽出API(例:Firecrawl) 対応 高 インフラ管理は不要だが、1万ページあたり10〜50ドル発生。データがVPC外へ流出
Crawl4AI(セルフホスト) 対応(Playwright) 極めて高い(独自のfit_markdownヒューリスティクス) コンテキストあたり120〜180MB RAM。自社インフラ内ですべて完結

サードパーティのAPIは手早いプロトタイピングには適しています。しかし、企業の機密ドキュメントや数十万ページ規模のデータを従量課金の外部エンドポイントに送信することは、コンプライアンス上の懸念や予測不能なランニングコストを招きます。Crawl4AIはこの課題を的確に解決します。ローカルで非同期Pythonエンジンとして動作し、Playwrightを介してChromiumを制御しながら、定型コードを除去してエンベディングモデルに最適化された構造化Markdownを出力します。

実装:WebページからクリーンなMarkdownへの変換

以下のワークフローにより、動的なWebデータをクリーンかつトークン効率に優れたMarkdownへ変換し、ベクトルパイプラインへ即座に投入できるようにします。

1. 環境のセットアップ

Crawl4AIの内部ではPlaywrightが利用されています。Pythonパッケージをインストールし、必要なブラウザバイナリをダウンロードします。

pip install crawl4ai
crawl4ai-setup

2. 基本的な非同期抽出

最小限のクローラー構成では、非同期ブラウザセッションを初期化し、対象のURLを取得してクリーンなMarkdownをレンダリングします。

import asyncio
from crawl4ai import AsyncWebCrawler

async def fetch_simple_markdown(url: str):
    async with AsyncWebCrawler(verbose=False) as crawler:
        result = await crawler.arun(url=url)
        
        if result.success:
            print("--- 抽出されたMarkdownサンプル ---")
            print(result.markdown[:400])
            print(f"\n総文字数: {len(result.markdown):,}")
        else:
            print(f"クロール失敗: {result.error_message}")

if __name__ == "__main__":
    asyncio.run(fetch_simple_markdown("https://news.ycombinator.com"))

3. ヒューリスティックプルーニングによるレイアウトノイズの除去

標準的な変換処理では、サイドバーやCookieポリシー、多層ネストされたナビゲーションリンクが出力に残ってしまいます。Crawl4AIはPruningContentFilterによってこの問題に対処します。DOMサブツリー全体のテキスト密度をスコアリングし、最終テキストを生成する前に重複するリンクブロックやナビゲーションラッパーを枝刈り(プルーニング)します。

import asyncio
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
from crawl4ai.content_filter_strategy import PruningContentFilter
from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator

async def extract_optimized_content(url: str) -> str:
    browser_config = BrowserConfig(
        headless=True,
        verbose=False,
    )

    # テキスト対タグ密度の比率が低いノードを破棄
    prune_filter = PruningContentFilter(
        threshold=0.45,
        threshold_type="fixed",
        min_word_threshold=10
    )
    
    md_generator = DefaultMarkdownGenerator(
        content_filter=prune_filter,
        options={"ignore_links": False}
    )

    run_config = CrawlerRunConfig(
        cache_mode=CacheMode.BYPASS,
        markdown_generator=md_generator,
        word_count_threshold=15,
        excluded_tags=["nav", "footer", "aside", "script", "style", "header"],
        exclude_external_links=True
    )

    async with AsyncWebCrawler(config=browser_config) as crawler:
        result = await crawler.arun(url=url, config=run_config)
        
        if result.success:
            # fit_markdown には枝刈り済みの高密度コンテンツが格納される
            return result.markdown.fit_markdown
        return ""

if __name__ == "__main__":
    target = "https://en.wikipedia.org/wiki/Retrieval-augmented_generation"
    markdown_output = asyncio.run(extract_optimized_content(target))
    print(f"高密度コンテンツを {len(markdown_output):,} 文字抽出しました。")
    print(markdown_output[:600])

レイアウト用のコンテナ(<nav>、<footer>、<aside>)を除外したことで、私たちの環境ではトークン数が平均58%削減されました。10,000ページのコーパス換算では、ベクトルインデックス全体のサイズを4,200万トークンから1,760万トークンへと大幅に縮小できました。

4. 動的SPAと無限スクロールのスクレイピング

近年のWebアプリケーションは、バックグラウンドでのXHRリクエストが完了するまでデータ描画を遅延させることが一般的です。Crawl4AIでは、スナップショットを抽出する前にJavaScriptルーチンを注入し、明示的なCSS待機条件を指定することでこれに対応します。

import asyncio
from crawl4ai import AsyncWebCrawler, CrawlerRunConfig

async def scrape_dynamic_page(url: str):
    # 遅延読み込みされるセクションをトリガーするためにスクロールを実行
    scroll_script = """
    window.scrollTo(0, document.body.scrollHeight);
    await new Promise(resolve => setTimeout(resolve, 800));
    """

    config = CrawlerRunConfig(
        js_code=scroll_script,
        wait_for="css:article.main-content",
        delay_before_return_html=1.0
    )

    async with AsyncWebCrawler() as crawler:
        result = await crawler.arun(url=url, config=config)
        if result.success:
            return result.markdown.fit_markdown
        return None

ナレッジ取り込みのための本番運用ガイドライン

クローラーを本番環境へ移行する際には、厳格な運用ガードレールが欠かせません。以下は、私たちの取り込みワーカーで採用している主要なルールです。

  • コンテンツハッシュによる重複排除:変更のないページを再エンベディングしてはなりません。result.markdown.fit_markdownのSHA-256ハッシュを算出し、後続のチャンク分割処理を呼び出す前にメタデータストアと照合・検証してください。
  • ブラウザの同時実行数の上限設定:ヘッドレスChromiumインスタンスは、適切に制御しないとメモリリークを起こすリスクがあります。並行ページ1枚あたり120MB〜180MBのRAMを見積もってください。標準的な4 vCPU・16GB RAMのクラウドノードであれば、asyncio.Semaphoreを用いてワーカープールの同時コンテキスト数を10〜12個前後に制限するのが安全です。
  • カスタムUser-Agentのローテーション:正規の最新ブラウザのUser-Agent文字列を設定してください。デフォルトのヘッドレスヘッダーのままアクセスすると、主要な開発者向けドキュメントサイトでCloudflareやCloudFrontのBot検証(チャレンジ)に即座にブロックされてしまいます。

入力データがクリーンであれば、生成されるエンベディングも高精度になります。DOMのプルーニングとMarkdown生成をクロール段階へシフトすることで、ベクトルのチャンクがMarkdownの見出し構造と自然に一致するようになります。これにより、後続のモデルはページナビゲーションの不要な残骸に惑わされず、ドメイン知識の核心に集中できるようになります。

Share: