Embedding RAGの限界とPageIndex:ベクターレスRAGを自社サイトで試した【LangGraph実装付き】

要約

BM25 + Embeddingのハイブリッド検索+Rerankを試したが、キーワードのない質問や意味的に遠い表現には全く歯が立たなかった。そこで PageIndex(ベクターレスRAG)を自社サイトのAIアシスタントに実装したところ、以下の成果が得られた。

  • VectorDB・Embeddingモデル不要でRAGを構築できた
  • URLツリー+LLM 2段階絞り込みにより、コンテキストを最小限にしつつ探索できた
  • LangGraphによるエージェントワークフローで「現在閲覧中のページへの質問」と「サイト全体への汎用質問」を自動で振り分け
  • 実ログを含む一次検証として、どんな製品がある?ASRに関するブログは何がある?の質問が正しいページへ到達することを確認済み

この記事で得られる知識:

  1. 従来のEmbedding RAGが抱える本質的な問題
  2. PageIndexの仕組みとURL階層構造の構築方法
  3. LangGraphを使ったRAGエージェントの全ノード実装コード
  4. 実運用での注意点とスケールアップの課題

1. 従来のEmbedding RAGの何が問題だったか {#embedding-rag-problem}

RAG(Retrieval-Augmented Generation)の定番アーキテクチャといえば、BM25(全文検索)+ Embedding(ベクター検索)のハイブリッド+Rerankだ。理論上はキーワードと意味の両方をカバーできるはずなのだが、実際に自社サービスのAIアシスタントとして運用してみると、以下の3つの壁にぶつかった。

壁1:キーワードも意味も外れた質問には完全に無力

ユーザーが使う表現は多様だ。たとえばドキュメントに「認証」と書いてあっても、ユーザーは「ログインのやり方」と聞く。BM25は当然ミスし、Embeddingも訓練データの分布によっては意味的距離を正しく測れない。どんなに賢いLLMを使っても、Retrieverが外したらそもそも答えようがない。

壁2:Embeddingモデルの性能に全てが左右される

日本語のEmbeddingモデルは英語と比べてまだ選択肢が少なく、ドメイン特化したコンテンツ(音声処理、医療、法律など)では汎用モデルのベクター表現が粗くなりがちだ。Fine-tuningするにしても、それはそれで別の工数がかかる。

壁3:インフラコストと運用複雑性

  • VectorDBの構築・管理(Pinecone、Weaviate、pgvector等)
  • Embeddingモデルのデプロイ(またはAPI費用)
  • Rerankモデルの追加デプロイ
  • これらを統合するパイプラインの保守

3つのAIコンポーネントを並行して動かす必要があり、スモールスタートが難しい。特に自社サイト程度のスケールでは過剰投資になりやすい。


2. PageIndexとは何か {#what-is-pageindex}

PageIndexは、ベクターを一切使わずにLLMだけで検索を完結させるRAGアーキテクチャだ。

コアの発想は「本の目次」に近い。本を全ページ読まなくても、目次を見て章→節→項と絞り込めば目的の情報にたどり着ける。これをAIが自動でやる。

PageIndexの元々の設計

オリジナルのPageIndexはPDFやMarkdownをHeadingで階層化する。# Chapter1 > ## Section1 > ### Subsection1のような構造だ。

今回の拡張:URLベースの階層

自社サイトへの適用にあたり、URLパスそのものをツリー構造として扱う方式を採用した。つまり:

text
https://neosophie.com/
├── /ja/blog/
│   ├── /ja/blog/20260226-japanese-asr-benchmark
│   ├── /ja/blog/20260305-vibevoice-quantization
│   └── /ja/blog/tags/
│       ├── /ja/blog/tags/asr
│       └── /ja/blog/tags/diarization
├── /products
└── /contact

このツリーをLLMに見せて「どのサブツリーに答えがありそうか」を推論させる。Embeddingなし、VectorDBなし、BM25なし。LLM 1本で検索プランを立てる。

Claudeのskillシステムとの類似性

この考え方はClaudeのskillシステムに近い。数百のskillファイルを全て読まずに、各skillの概要だけ読んで「このskillが使えそうか」判断してから詳細を読む——それと同じ階層的絞り込みをRAGで実現している。


3. URL階層ツリーの構築:ステップバイステップ {#url-tree}

前提:クロール済みURLの収集

まず対象ドメインの全URLをクローラーで収集する。クロール済みのURLが以下のようなsetとして得られているとする。

python
page_by_path = {
    "/": {"title": "Neosophie", "content": "..."},
    "/products": {"title": "Products", "content": "..."},
    "/ja/blog/20260226-japanese-asr-benchmark": {"title": "日本語ASRモデル比較", "content": "..."},
    "/ja/blog/tags/asr": {"title": "タグ: asr", "content": "..."},
    # ...
}

ステップ1:祖先パスの展開

クロール済みURLが /docs/api/auth だけでも、その中間パスを全て生成する。

python
# url_tree.py
def _ancestor_paths(path: str) -> list[str]:
    """
    "/docs/api/auth" → ["/docs/api/auth", "/docs/api", "/docs", "/"]
    """
    result: list[str] = [path]
    current = path
    while True:
        if current == "/":
            break
        parent = current.rsplit("/", 1)[0] or "/"
        result.append(parent)
        current = parent
    return result

# 全クロール済みURLに対して祖先パスを収集
all_paths: set[str] = set()
for path in list(page_by_path):
    all_paths.update(_ancestor_paths(path))

なぜこれが必要か? /ja/blog/20260226-japanese-asr-benchmarkはクロール済みでも、/ja/ja/blogはクロール対象外の場合がある。中間ノードが存在しないとツリーが組めないため、祖先パスを補完する。

ステップ2:ノードの生成(クロール済みフラグ付き)

python
from dataclasses import dataclass, field

@dataclass
class UrlTreeNode:
    segment: str          # パスの末尾セグメント(例: "auth")
    path: str             # フルパス(例: "/docs/api/auth")
    url: str              # 完全URL
    title: str            # ページタイトル(クロール済みのみ)
    crawled: bool         # 実際に取得したページかどうか
    children: list["UrlTreeNode"] = field(default_factory=list)

nodes: dict[str, UrlTreeNode] = {}
for path in sorted(all_paths):
    page = page_by_path.get(path)
    crawled = page is not None
    segment = path.rsplit("/", 1)[-1] or "/"
    nodes[path] = UrlTreeNode(
        segment=segment,
        path=path,
        url=f"https://example.com{path}",
        title=page.get("title", "") if page else "",
        crawled=crawled,
    )

ステップ3:親子リンクでツリー組み立て

python
roots: list[UrlTreeNode] = []
for path, node in nodes.items():
    if path == "/":
        roots.append(node)
        continue
    parent_path = path.rsplit("/", 1)[0] or "/"
    parent = nodes.get(parent_path)
    if parent is not None:
        parent.children.append(node)
    else:
        roots.append(node)  # 親がなければルート扱い

ステップ4:単一子の中間ノードを collapse(圧縮)

/ja(未クロール)の子が /ja/blog だけなら、/ja ノードを飛ばして直接親に繋ぐ。ツリーの深さを不要に増やさないための最適化。

python
def _collapse_single_child_intermediates(
    nodes: list[UrlTreeNode],
) -> list[UrlTreeNode]:
    result = []
    for node in nodes:
        # 再帰的に子を先に処理
        node.children = _collapse_single_child_intermediates(node.children)
        # 未クロール かつ 子が1つだけ → このノードをスキップ
        if not node.crawled and len(node.children) == 1:
            result.append(node.children[0])
        else:
            result.append(node)
    return result

変換イメージ:

text
Before:
/
└── ja (未クロール、子が1つ)
    └── blog (クロール済み)

After:
/
└── blog  ← /ja が消えて直結

4. LangGraphで組むRAGエージェントの全ノード解説 {#langgraph-agent}

LangGraphを使ってノードベースのエージェントワークフローを構築した。全体は以下の6ノードで構成される。

Rendering diagram...

Node 1:classify — 質問の種別判定

「このページを要約して」のような現在閲覧ページへの質問と、サイト全体への汎用質問を振り分ける。

python
import re

_CURRENT_PAGE_PATTERN = re.compile(
    r"このページ|今のページ|今見ている|ここ[にのはをが]|要約|まとめ|説明して",
)

def _node_classify(self, state: dict) -> dict:
    question = state.get("question", "")
    current_url = state.get("context_info", {}).get("current_url")
    # ユーザーが特定ページを開いている かつ パターンマッチ
    if current_url and self._CURRENT_PAGE_PATTERN.search(question):
        return {"question_type": "current_page"}
    return {"question_type": "general"}

実装メモ: 現状は正規表現のみだが、「このサービスについて教えて」のような間接的な表現を拾いきれない。LLMによる分類器の追加を検討中。

Node 2a:fetch_current_page(current_page ルート)

URLで直接ページを引く。インデックスになければ先頭4件にフォールバック。

python
def _node_fetch_current_page(self, state: dict) -> dict:
    current_url = state.get("context_info", {}).get("current_url", "")
    index = self._load_page_index()
    pages_by_url = {p["url"]: p for p in index.get("pages", [])}
    page = pages_by_url.get(current_url)
    if page:
        return {"selected_pages": [page]}
    # フォールバック:先頭4件
    return {"selected_pages": list(pages_by_url.values())[:4]}

Node 2b:build_subtrees(general ルート)

URLツリーをサブツリー単位に変換し、各サブツリーが持つリーフページ一覧を付与する。

python
def _node_build_subtrees(self, state: dict) -> dict:
    index = self._load_page_index()
    url_tree = index.get("url_tree", [])
    pages_by_url = {p["url"]: p for p in index.get("pages", [])}
    seen_paths: set[str] = set()
    subtrees: list[dict] = []

    # ルート直下の子を優先的に追加(重要度が高いため)
    for root in url_tree:
        for child in root.get("children", []):
            path = child.get("path", "")
            if path not in seen_paths:
                seen_paths.add(path)
                leaf_pages = self._collect_all_leaf_pages(child, pages_by_url)
                subtrees.append({
                    "path": path,
                    "url": child.get("url", ""),
                    "title": child.get("title", ""),
                    "leaf_pages": leaf_pages,
                })

    # さらに深い非リーフノードも追加(重複はseen_pathsで除外)
    self._collect_non_leaf_subtrees(url_tree, pages_by_url, subtrees, seen_paths)
    return {"subtrees": subtrees}

@staticmethod
def _collect_all_leaf_pages(
    node: dict,
    pages_by_url: dict,
) -> list[dict]:
    """サブツリーのリーフページを再帰的に収集"""
    children = node.get("children", [])
    if not children:
        # 末端ノード → そのページを返す
        page = pages_by_url.get(node.get("url", ""))
        return [page] if page else []
    # 中間ノード → 子を再帰して集約
    result = []
    for child in children:
        result.extend(
            RagAgentService._collect_all_leaf_pages(child, pages_by_url)
        )
    return result

Node 3:select_subtrees — LLMがサブツリーを選ぶ(第1段階絞り込み)

TOC(目次)形式のテキストをLLMに渡し、最大2つのサブツリーIDを返させる。

python
def _node_select_subtrees(self, state: dict) -> dict:
    question = state.get("question", "")
    subtrees = state.get("subtrees", [])

    # TOC形式のテキスト組み立て
    toc_lines = []
    for i, st in enumerate(subtrees, start=1):
        preview = [p.get("title", "") for p in st["leaf_pages"][:6]]
        titles_str = ", ".join(preview)
        if len(st["leaf_pages"]) > 6:
            titles_str += f" 他{len(st['leaf_pages']) - 6}件"
        toc_lines.append(
            f"id={i} | section={st['title']} | url={st['url']} | "
            f"ページ数={len(st['leaf_pages'])} | 含まれるページ: {titles_str}"
        )

    system_prompt = (
        "あなたは検索プランナーです。"
        "サイト構造から質問に最も関連するセクションIDを最大2件選んでください。"
        "必ずJSON配列のみで返してください。例: [1]"
    )
    human_prompt = f"質問: {question}\n\nサイト構造:\n" + "\n".join(toc_lines)

    response = self._llm.invoke(
        [SystemMessage(content=system_prompt), HumanMessage(content=human_prompt)]
    )
    selected_ids = self._extract_id_list(response.content)  # "[1, 3]" → [1, 3]

    selected_paths = [
        st["path"]
        for i, st in enumerate(subtrees, start=1)
        if i in set(selected_ids)
    ]
    return {"selected_subtree_paths": selected_paths}

Node 4:collect_leaves → Node 5:select_pages(第2段階絞り込み)

選ばれたサブツリー内のリーフページを集め、ページ単位でLLMに最大4件絞らせる。

python
def _node_select_pages(self, state: dict) -> dict:
    question = state.get("question", "")
    leaf_candidates = state.get("leaf_candidates", [])

    # ページ目次の組み立て(headingsを使ってページ内容を要約)
    page_lines = []
    for i, page in enumerate(leaf_candidates, start=1):
        headings = page.get("headings", [])
        headings_str = ", ".join(headings[:5])
        page_lines.append(
            f"id={i} | title={page.get('title', '')} "
            f"| url={page.get('url', '')} | headings={headings_str}"
        )

    system_prompt = (
        "あなたは検索プランナーです。"
        "目次候補から質問に最も関連するページIDを最大4件選んでください。"
        "必ずJSON配列のみで返してください。例: [3, 8]"
    )
    human_prompt = f"質問: {question}\n\n目次候補:\n" + "\n".join(page_lines)

    response = self._llm.invoke(
        [SystemMessage(content=system_prompt), HumanMessage(content=human_prompt)]
    )
    selected_ids = self._extract_id_list(response.content)

    selected_pages = [
        page
        for i, page in enumerate(leaf_candidates, start=1)
        if i in set(selected_ids)
    ]
    return {"selected_pages": selected_pages}

Node 6:answer — 最終回答生成

選択されたページのcontentを連結してコンテキストを構築し、LLMに日本語で回答させる。

python
def _node_answer(self, state: dict) -> dict:
    question = state.get("question", "")
    selected_pages = state.get("selected_pages", [])
    context_info = state.get("context_info", {})

    # コンテキスト文字列の組み立て(最大14,000文字)
    context_parts = []
    total_chars = 0
    for i, page in enumerate(selected_pages, start=1):
        content = page.get("content", "")[:3500]  # 1ページあたり上限
        entry = (
            f"[{i}] title={page.get('title', '')}\n"
            f"url={page.get('url', '')}\n"
            f"headings={page.get('headings', '')}\n"
            f"content={content}"
        )
        total_chars += len(entry)
        if total_chars > 14000:
            break
        context_parts.append(entry)

    context = "\n\n".join(context_parts)

    system_prompt = (
        "あなたはWebサイト案内AIです。"
        "与えられた文脈だけを根拠に日本語で回答してください。"
        "文脈が不足する場合は不足を明示してください。"
        "表は絶対に使わないこと。情報の整理には箇条書きを優先してください。"
        "最後に参照URLを箇条書きで示してください。"
    )
    human_prompt = (
        f"ユーザーコンテキスト:\n"
        f"- ユーザーが閲覧中のURL: {context_info.get('current_url', 'N/A')}\n"
        f"- 現在時刻: {context_info.get('timestamp', 'N/A')}\n\n"
        f"文脈:\n{context}\n\n"
        f"質問: {question}"
    )

    response = self._llm.invoke(
        [SystemMessage(content=system_prompt), HumanMessage(content=human_prompt)]
    )
    return {"answer": response.content}

5. 実ログで見る動作検証 {#real-logs}

実際に動かした際のサーバーログを抜粋して紹介する。

ケース1:「どんな製品がある?」

select_subtrees ノードでLLMに渡されたサイト構造(一部抜粋):

text
id=1 | section=Contact | Neosophie | url=https://neosophie.com/contact | ページ数=1
id=2 | section=ブログ | Neosophie | url=https://neosophie.com/ja/blog | ページ数=17 | 含まれるページ: AIエンジニアが毎日チェック...他11件
id=4 | section=Products | Neosophie | url=https://neosophie.com/products | ページ数=1 | 含まれるページ: Products | Neosophie

→ LLMは [4] を選択。/products ページへ直接到達。ベクター計算ゼロで正解。

ケース2:「ASRに関するブログは何がある?」

ステップ1: サブツリーの選択

まずサイト全体のURLツリーを要約した目次をLLMに渡す。

text
質問: ASRに関するブログは何がある?

サイト構造:
id=1 | section=Contact | url=https://neosophie.com/contact | ページ数=1 | ...
id=2 | section=ブログ  | url=https://neosophie.com/ja/blog | ページ数=17 | 含まれるページ: AIエンジニアが毎日チェック..., 【2026年】NeMo・VibeVoice..., ...

LLMは [2](ブログセクション)を選択。全ページを見ずにブログ配下に絞り込める。

ステップ2: ページの選択

選ばれたサブツリー(ブログ)配下の17件を目次形式でLLMに渡す。

text
質問: ASRに関するブログは何がある?

目次候補:
id=20 | title=AIエンジニアが毎日チェックする情報収集サイト17選 | headings=...
id=21 | title=【2026年最新】日本語ASRモデル比較:Whisper・Qwen3・Voxtral・ReazonSpeech | headings=要約, テスト環境...
id=6  | title=VibeVoice-ASRを量子化してDERはどう変わるか? | headings=要約, はじめに...
id=11 | title=タグ: asr | headings=# asr, VibeVoice-ASR..., 【2026年最新】日本語ASR...
...(17件)

LLMは [21, 6, 11] を選択:

  • id=21: 日本語ASRモデル比較(Whisper・Qwen3・Voxtral・ReazonSpeech)
  • id=6: VibeVoice-ASRを量子化してDERはどう変わるか?
  • id=11: タグ: asr(ASRタグページ)

ステップ3: 回答生成

選ばれた3ページの本文テキストをコンテキストとして組み立て、最終的にLLMが回答を生成する。

text
文脈:
[1] title=【2026年最新】日本語ASRモデル比較 ...
    url=https://neosophie.com/ja/blog/20260226-japanese-asr-benchmark
    content=8種類の日本語音声認識(ASR)モデルを同一条件でベンチマーク...

[2] title=VibeVoice-ASRを量子化してDERはどう変わるか? ...
...

「ASRに関するブログ」という質問に対し、ASRタグページと具体的なASR記事2本を正しく特定できた。サイト全体のページ数に関わらず、サブツリー選択→ページ選択の2段階で絞り込むことで、LLMに渡すトークン数を最小限に抑えられている。


6. 用語解説

用語説明
RAGRetrieval-Augmented Generation。LLMが回答する前に関連情報を検索して文脈として与える手法
BM25単語の出現頻度と逆文書頻度に基づく古典的な全文検索アルゴリズム
Embeddingテキストを数百〜数千次元のベクトルに変換し、意味的な近さを距離で表す技術
Rerank検索結果の候補を再スコアリングして順位を調整するモデル
VectorDBベクトルデータを高速に検索するためのデータベース(Pinecone、Weaviate等)
PageIndexヘッディングやURLパスによる階層構造でコンテンツをインデックスし、LLMが目次をたどる形で検索するベクターレスRAG手法
LangGraphLangChainのサブライブラリ。グラフ構造でLLMエージェントのワークフローを定義できる
リーフノードツリー構造の末端ノード。子を持たない最末端の要素

7. 実運用での課題と今後の改善案 {#challenges}

課題1:大規模サイトでのコンテキスト超過

現状はルート直下のサブツリーを全て渡しているが、数千ページ規模のサイトでは select_subtrees に渡すTOCだけでコンテキストを超過する恐れがある。

改善案: LLMによる事前グルーピング。類似するサブツリーをクラスタリングしてまず大分類を選ばせ、その後に詳細を渡す3段階方式にする。

課題2:ツリー分けが粗いと目的のページが見つからない

URLの構造がフラットなサイト(全ページが / 直下にある等)ではツリーが機能しない。その場合はコンテンツベースの階層(ページのHeadingや本文のクラスタリング)と組み合わせる必要がある。

課題3:ページコンテンツの鮮度管理

サイトが更新されるたびに再クロール+インデックス再構築が必要。VectorDBでも同様だが、差分更新の仕組みが必要になる。


FAQ {#faq}

Q1. PageIndexはEmbedding RAGより常に優れているのか?

優れているとは言い切れない。PageIndexはURL構造やヘッディングが整備されている「構造化されたコンテンツ」に強い。一方、非構造化データ(大量の短いテキスト断片、会話ログ等)や、意味的な類似度が問われる検索(「猫っぽい雰囲気のレストラン」のような曖昧クエリ)にはEmbeddingの方が向いている。用途に応じた使い分けが重要。

Q2. LangGraphを使わなくても実装できるか?

できる。ノードは単純な関数の連鎖なので、純粋なPythonの関数呼び出しチェーンでも問題ない。LangGraphを使う利点は状態管理・条件分岐・デバッグのしやすさにある。

Q3. LLMの呼び出し回数が増えてコストが上がらないか?

1クエリあたりLLM呼び出しは最大3回(サブツリー選択・ページ選択・回答生成)。ただし select_subtrees と select_pages は入力が構造化されたTOCテキストなので、安価な小型モデル(claude-haiku、gpt-4o-mini等)で十分機能する。回答生成のみ高性能モデルを使うハイブリッド戦略でコストを抑えられる。

Q4. VectorDBはゼロコストで廃止できるのか?

今回の実装では完全に不要になった。ただしページコンテンツはJSONやSQLiteに保存しておく必要があり、フルテキスト検索が必要な場面ではBM25と組み合わせるハイブリッド構成に戻す選択肢も残しておくべきだ。

Q5. ページのコンテンツが長すぎてもうまくいくか?

_node_answer内でページあたりの文字数を3,500文字に制限し、合計14,000文字を上限としている。コンテンツが長い場合はHeadingベースのチャンキングを事前に施してからインデックスに保存すると良い。

Q6. 動的に生成されるページ(Next.js等のSPA)には対応しているか?

クローラーがJavaScriptをレンダリングできる必要がある(Playwright等を使用)。コンテンツが静的に取得できれば、PageIndex側の処理は変わらない。


まとめ

比較項目Embedding RAGPageIndex
VectorDB必要不要
Embeddingモデル必要不要
Rerankモデル推奨不要
LLM呼び出し回数1回(回答のみ)最大3回
キーワード外れへの耐性低い高い
構造化コンテンツ適性高い
非構造化コンテンツ適性高い低い
実装・運用コスト高い低い

PageIndexは「シンプルな構造のサイト×限られたリソース×高い精度要求」という条件が揃ったときに最も輝く。VectorDBのセットアップに時間を割く前に、まずPageIndexで試してみる価値は十分にある。

関連するブログ

この記事に近いテーマのブログをピックアップしています。