はじめに
Retrieval-Augmented Generation(RAG)は、外部知識を検索してLLMに回答させる代表的な手法です。一般的には ベクトル検索(Embedding+ベクトルDB)を使いますが今回紹介するのは、PageIndexが提供する Vectorless RAGです。文書をアップロードすると自動で「OCR → Markdown → Tree構造化」してくれるので、ベクトル検索を使わずに「章節ベースの検索」が可能になります。
この記事では、Python から PageIndex API を利用して Vectorless RAG パイプライン を構築し、実際に動かしてみた手順と結果を解説しています。
PageIndexとは
概要
PageIndex は、人間の専門家が文書を閲覧して知識を抽出する方法をシミュレートする、推論ベースの検索システムです。ベクトルベースの意味的類似性検索に依存する代わりに文書を階層ツリー構造に変換し、構造化されたツリー検索を行って最も関連性の高い情報を特定・取得しています。
既存のベクトルベースのRAGは意味的類似性に依存します。 それに対してPageIndexは、類似性ではなく関連性に重きを置いています。理由としてベクトルベースRAGは似た「雰囲気」を持つコンテンツを効率的に特定できますが、特に多くのセクションが似た言語を共有しつつ重要な詳細で異なる専門領域において、必要な正確な情報を取得することにしばしば失敗するためです。
出典の原文はこちら↓
動画での解説↓
PageIndex 料金プラン
プラン | 料金 | 特徴 |
---|---|---|
Free Trial | 無料 | OCR & Tree Generation 利用可(最大200ページ) コミュニティサポート |
Standard Plan | 従量課金制 | $0.01/ページ 無制限ページ (OCR & Tree Generation) 文書解析向き メールサポート ※ Retrieval機能なし |
Retrieval Plan | $50/月 | 月額50クレジット込み 無制限ページ (OCR & Tree Generation) Retrieval無制限 $0.01/ページ $0.02/クエリ $0.04/思考型クエリ 24/7 優先サポート |
Enterprise | カスタム価格 | カスタムソリューション 専任サポートチーム シングルテナントクラスター BYOC対応 SLA保証 オンプレ展開対応 |
無料枠を超えるとStandard PlanPay as you goに移行するため支払い方法の登録が必要になります。
詳しくは:https://dash.pageindex.ai/usage
実際に使ってみる(ハンズオン)
使用するライブラリのインストール
pip install pageindex openai
PageIndex APIKey取得
① PageIndexページにアクセス
④API Keysを選択しCreate New API Keyを押しAPIKeyに名前を付け取得
OpenAIのAPIKeyを取得
アカウント登録は割愛・取得法も簡単な紹介とします。
①APIプラットフォームにログイン
②Dashboardにアクセス
③API keys→Create New API keyからAPIkeyを取得
PDF作成
今回は以下の文言のPDFを使用しました。
PageIndexではPDF内の記述をMarkdown形式にすることを推奨します。
Markdown以外の文書を使った場合見出しや章立てがないため、Tree(章節ツリー)が空のままになることでエラーになる可能性がありそうです。
OCR → Tree 化のステップは即時ではなく、数十秒〜数分かかることがあります。
Markdown以外の文書を使った場合見出しや章立てがないため、Tree(章節ツリー)が空のままになることでエラーになる可能性がありそうです。
使用した文章↓(適当に投げたらGPT-5が出してくれました)
# 長文テキスト(Markdown形式)
## 第1章 港町の少年
ある小さな港町に、一人の若者が住んでいた。彼の名前はアレン。彼は船乗りでも商人でもなく、ただしかし、彼の心の奥底には強い憧れがあった。遠くへ旅立ち、まだ見ぬ世界を自分の目で確かめたいと
## 第2章 森の伝説
町の北には古くから「時の森」と呼ばれる森が広がっていた。
その中心には「時を超える泉」があると言われている。泉を覗き込んだ者は、自分の過去や未来を垣間
## 第3章 出発の決意
ある晩、静かな月明かりの下で、アレンは決意した。
「自分の未来を知りたい。そして、それを選び取る勇気を持ちたい」。
## 第4章 老いた語り部
森を進む途中、アレンは一人の旅人と出会った。白髪をなびかせた老人で、杖を手に持ち、落ち着いた「若者よ、泉を目指すのか? あの泉は望む答えを与えてはくれぬ。映し出されるのは、お前自身の心だ
## 第5章 試練の始まり
泉へ辿り着くには、三つの試練を超えねばならなかった。
最初の試練は「恐怖の影」。森の奥深く、アレンは黒い影に出会った。
それは彼自身の恐怖の化身だった。
## 第6章 記憶の渦
次の試練は「記憶の渦」。川のほとりに立ったアレンの目の前で、水面が大きく渦を巻いた。
そこには彼の過去の記憶が次々と浮かんできた。
## 第7章 孤独の鐘
最後の試練は「孤独の鐘」。深い洞窟に入り込んだアレンは、暗闇の中で鐘の音を聞いた。
それは彼の心を試す音であり、誰も助けてはくれなかった。
## 第8章 泉の真実
三つの試練を越えたアレンは、ついに泉の前に辿り着いた。
泉は月光を浴び、銀色に輝いていた。水面を覗き込むと、そこに映ったのは未来でも過去でもなく、
今を生きる自分の姿だった。
## 第9章 帰還
町に戻ったアレンは、以前と同じ仕事を続けた。しかし彼の心は変わっていた。
夢見るだけではなく、自らの選択で未来を切り開く覚悟ができていた。
## 第10章 物語の継承
アレンの物語はやがて人々に語られるようになった。
彼の勇気は若者たちに希望を与え、彼の言葉は多くの人を導いた。
ハンズオン1 ページインデックスの取得をしてみる
このステップのゴールは 「PDF →(OCR)→ Markdown → ツリー化」 の一連の流れを体験し、PageIndexが返すJSONの形を掴むことです。コードでは次の順で動きます。
-
ドキュメント送信(submit_document() で doc_id を取得)
-
OCR完了待ち(get_ocr(doc_id) をポーリングして status=completed になるまで待機)
-
Retrieval( submit_query() → get_retrieval() で関連ノードを取得)
from pageindex import PageIndexClient
import time
import json
# === APIキー ===
API_KEY = "PageIndexのAPIKey"
pi = PageIndexClient(api_key=API_KEY)
# === 1. ドキュメントを送信 ===
submit_result = pi.submit_document("PDF_Path")
doc_id = submit_result["doc_id"]
print(f"Document submitted. doc_id = {doc_id}")
# === 2. OCR 処理待ち ===
while True:
ocr_status = pi.get_ocr(doc_id)
print("OCR status:", ocr_status.get("status"))
if ocr_status.get("status") == "completed":
break
time.sleep(5)
ocr_result = pi.get_ocr(doc_id)["result"]
print("OCR Result sample:", json.dumps(ocr_result[0], indent=2, ensure_ascii=False))
# === 3. Tree生成待ち ===
while True:
tree_status = pi.get_tree(doc_id)
print("Tree generation status:", tree_status.get("status"))
if tree_status.get("status") == "completed":
break
time.sleep(5)
tree_result = pi.get_tree(doc_id)["result"]
print("Tree:", json.dumps(tree_result, indent=2, ensure_ascii=False))
if not tree_result:
print("Tree is empty. Retrieval cannot be used, fallback to OCR search.")
# OCRから簡易全文検索
query = "泉"
for page in ocr_result:
if query in page["markdown"]:
print("Found on page", page["page_index"])
print(page["markdown"][:200])
else:
# === 4. Retrieval ===
retrieval = pi.submit_query(doc_id, "最後の試練は?")
retrieval_id = retrieval["retrieval_id"]
print(f"Query submitted. retrieval_id = {retrieval_id}")
# ステータス待ち
while True:
retrieval_status = pi.get_retrieval(retrieval_id)
print("Retrieval status:", retrieval_status.get("status"))
if retrieval_status.get("status") == "completed":
break
time.sleep(5)
retrieval_result = pi.get_retrieval(retrieval_id)["retrieved_nodes"]
# === 5. 結果出力 ===
print("Retrieved Nodes:")
for node in retrieval_result:
print(" - Title:", node.get("title"))
print(" Page:", node.get("page_index"))
# relevant_contents の安全な処理
contents = node.get("relevant_contents", [])
if contents and isinstance(contents, list):
if isinstance(contents[0], dict):
snippet = contents[0].get("relevant_content", "(なし)")
else:
snippet = str(contents[0])
else:
snippet = "(なし)"
print(" Content snippet:", snippet[:200])
生成結果
Document submitted. doc_id = pi-cmfmqf4fp008e0cpe7qp5f4vi
OCR status: processing
OCR status: processing
OCR status: processing
OCR status: completed
OCR Result sample: {
"images": [],
"markdown": "\n\n長文テキスト ( Markdown形式 )\n\n# 第1章 港町の少年\n\nある小さな港町に、一人の若者が住んでいた。彼の名前はアレン。彼は船乗りでも商人でもなく、ただの商人でもある。彼の心の奥底には強い憧れがあった。遠くへ旅立ち、まだ見ぬ世界を自分の目で確かめたいと思い、彼の心を支えている。\n\n# 第2章 森の伝説\n\n町の北には古くから「時の森」と呼ばれる森が広がっていた。\n\nその中心には「時を超える泉」があると言われている。泉を覗き込んだ者は、自分の過去や未来を垣間見ることができた。\n\n# 第3章 出発の決意\n\nある晩、静かな月明かりの下で、アレンは決意した。\n\n「自分の未来を知りたい。そして、それを選び取る勇気を持ちたい」。\n\n# 第4章 老いた語り部\n\n森を進む途中、アレンは一人の旅人と出会った。白髪をなびかせた老人で、杖を手に持ち、落ち着いた。老人は、その老人を助けようとすると、アレンは、老人を助けようとすると、老人が助けを求める。\n\n「若者よ、泉を目指すのか? あの泉は望む答えを与えてはくれぬ。映し出されるのは、お前自身の心との境目だ」\n\n# 第5章 試練の始まり\n\n泉へ辿り着くには、三つの試練を超えねばならなかった。\n\n最初の試練は「恐怖の影」。森の奥深く、アレンは黒い影に出会った。\n\nそれは彼自身の恐怖の化身だった。\n\n# 第6章 記憶の渦\n\n次の試練は「記憶の渦」。川のほとりに立ったアレンの目の前で、水面が大きく渦を巻いた。\n\nそこには彼の過去の記憶が次々と浮かんできた。\n\n# 第7章 孤独の鐘\n\n最後の試練は「孤独の鐘」。深い洞窟に入り込んだアレンは、暗闇の中で鐘の音を聞いた。\n\nそれは彼の心を試す音であり、誰も助けてはくれなかった。\n\n# 第8章 泉の真実\n\n三つの試練を越えたアレンは、ついに泉の前に辿り着いた。",
"page_index": 1,
"extended_node_candidates": []
}
Tree generation status: completed
Tree: [
{
"title": "第1章 港町の少年",
"node_id": "0000",
"page_index": 1,
"text": "# 第1章 港町の少年\n\nある小さな港町に、一人の若者が住んでいた。彼の名前はアレン。彼は船乗りでも商人でもなく、ただの商人でもある。彼の心の奥底には強い憧れがあった。遠くへ旅立ち、まだ見ぬ世界を自分の目で確かめたいと思い、彼の心を支えている。\n"
},
{
"title": "第2章 森の伝説",
"node_id": "0001",
"page_index": 1,
"text": "# 第2章 森の伝説\n\n町の北には古くから「時の森」と呼ばれる森が広がっていた。\n\nその中心には「時を超える泉」があると言われている。泉を覗き込んだ者は、自分の過去や未来を垣間見ることができた。\n"
},
{
"title": "第3章 出発の決意",
"node_id": "0002",
"page_index": 1,
"text": "# 第3章 出発の決意\n\nある晩、静かな月明かりの下で、アレンは決意した。\n\n「自分の未来を知りたい。そして、それを選び取る勇気を持ちたい」。\n"
},
{
"title": "第4章 老いた語り部",
"node_id": "0003",
"page_index": 1,
"text": "# 第4章 老いた語り部\n\n森を進む途中、アレンは一人の旅人と出会った。白髪をなびかせた老人で、杖を手に持ち、落ち着いた。老人は、その老人を助けようとすると、アレンは、老人を助けようとすると、老人が助けを求める。\n\n「若者よ、泉を目指すのか? あの泉は望む答えを与えてはくれぬ。映し出されるのは、お前自身の心との境目だ」\n"
},
{
"title": "第5章 試練の始まり",
"node_id": "0004",
"page_index": 1,
"text": "# 第5章 試練の始まり\n\n泉へ辿り着くには、三つの試練を超えねばならなかった。\n\n最初の試練は「恐怖の影」。森の奥深く、アレンは黒い影に出会った。\n\nそれは彼自身の恐怖の化身だった。\n"
},
{
"title": "第6章 記憶の渦",
"node_id": "0005",
"page_index": 1,
"text": "# 第6章 記憶の渦\n\n次の試練は「記憶の渦」。川のほとりに立ったアレンの目の前で、水面が大きく渦を巻いた。\n\nそこには彼の過去の記憶が次々と浮かんできた。\n"
},
{
"title": "第7章 孤独の鐘",
"node_id": "0006",
"page_index": 1,
"text": "# 第7章 孤独の鐘\n\n最後の試練は「孤独の鐘」。深い洞窟に入り込んだアレンは、暗闇の中で鐘の音を聞いた。\n\nそれは彼の心を試す音であり、誰も助けてはくれなかった。\n"
},
{
"title": "第8章 泉の真実",
"node_id": "0007",
"page_index": 1,
"text": "# 第8章 泉の真実\n\n三つの試練を越えたアレンは、ついに泉の前に辿り着いた。\n\n泉は月光を浴び、銀色に輝いていた。水面を覗き込むと、そこに映ったのは未来でも過去でもなく、\n\n今を生きる自分の姿だった。\n"
},
{
"title": "第9章 帰還",
"node_id": "0008",
"page_index": 2,
"text": "# 第9章 帰還\n\n町に戻ったアレンは、以前と同じ仕事を続けた。しかし彼の心は変わっていた。\n\n夢見るだけではなく、自らの選択で未来を切り開く覚悟ができていた。\n"
},
{
"title": "第10章 物語の継承",
"node_id": "0009",
"page_index": 2,
"text": "# 第10章 物語の継承\n\nアレンの物語はやがて人々に語られるようになった。\n\n彼の勇気は若者たちに希望を与え、彼の言葉は多くの人を導いた。\n"
}
]
Query submitted. retrieval_id = sr-cmfmqfl0w00eg0aperndunhl7
Retrieval status: processing
Retrieval status: processing
Retrieval status: completed
Retrieved Nodes:
- Title: 第7章 孤独の鐘
Page: None
Content snippet: [{'section_title': '第7章 孤独の鐘', 'physical_index': '<physical_index_1>', 'relevant_content': '最後の試練は「孤独の鐘」。深い洞窟に入り込んだアレンは、暗闇の中で鐘の音を聞いた。それは彼の心を試す音であり、誰も助けてはくれなかった。'}]
- Title: 第6章 記憶の渦
Page: None
Content snippet: [{'section_title': '第6章 記憶の渦', 'physical_index': '<physical_index_1>', 'relevant_content': '次の試練は「記憶の渦」。川のほとりに立ったアレンの目の前で、水面が大きく渦を巻いた。そこには彼の過去の記憶が次々と浮かんできた。'}]
- Title: 第8章 泉の真実
Page: None
Content snippet: [{'section_title': '第8章 泉の真実', 'physical_index': '<physical_index_1>', 'relevant_content': '三つの試練を越えたアレンは、ついに泉の前に辿り着いた。泉は月光を浴び、銀色に輝いていた。水面を覗き込むと、そこに映ったのは未来でも過去でもなく、今を生きる自分の姿だった。'}]
検索結果部分
Retrieved Nodes:
- Title: 第7章 孤独の鐘
Page: None
Content snippet: [{'section_title': '第7章 孤独の鐘', 'physical_index': '<physical_index_1>', 'relevant_content': '最後の試練は「孤独の鐘」。深い洞窟に入り込んだアレンは、暗闇の中で鐘の音を聞いた。それは彼の心を試す音であり、誰も助けてはくれなかった。'}]
- Title: 第6章 記憶の渦
Page: None
Content snippet: [{'section_title': '第6章 記憶の渦', 'physical_index': '<physical_index_1>', 'relevant_content': '次の試練は「記憶の渦」。川のほとりに立ったアレンの目の前で、水面が大きく渦を巻いた。そこには彼の過去の記憶が次々と浮かんできた。'}]
- Title: 第8章 泉の真実
Page: None
Content snippet: [{'section_title': '第8章 泉の真実', 'physical_index': '<physical_index_1>', 'relevant_content': '三つの試練を越えたアレンは、ついに泉の前に辿り着いた。泉は月光を浴び、銀色に輝いていた。水面を覗き込むと、そこに映ったのは未来でも過去でもなく、今を生きる自分の姿だった。'}]
第7章 孤独の鐘が検索結果の最上位に来ているため正しく検索が行えているということがわかります。また途中のログから章ごとにOCR機能を用いて文章を抽出して処理できているという過程が確認できます。
ハンズオン2 RAGを作成してみる
ここでは Retrieval の結果を LLM に流し込んで回答を生成します。やっていることはシンプルで、
-
PageIndex で関連ノードを取得(ハンズオン1と同じ流れ)
-
retrieved_nodes を プロンプト用テキスト(context)に整形
-
OpenAI Chat Completions API に投げて回答を生成
となっています。
from pageindex import PageIndexClient
from openai import OpenAI
import time
# === APIキー設定 ===
PAGEINDEX_API_KEY = "PageIndexのAPIKey"
OPENAI_API_KEY = "OpenAIのAPIKey"
pi = PageIndexClient(api_key=PAGEINDEX_API_KEY)
client = OpenAI(api_key=OPENAI_API_KEY)
# === 共通: ステータス待ち ===
def wait_for_status(get_status_func, doc_id, status_key="status", target="completed", interval=5):
while True:
resp = get_status_func(doc_id)
st = resp.get(status_key)
print(f"Status check: {st}")
if st == target:
return resp
time.sleep(interval)
# === RAG 実装 ===
def vectorless_rag_query(doc_path: str, user_query: str):
# 1. ドキュメント送信
submit = pi.submit_document(doc_path)
doc_id = submit["doc_id"]
print("Submitted doc:", doc_id)
# 2. OCR / Tree 完了待ち
wait_for_status(pi.get_ocr, doc_id)
wait_for_status(pi.get_tree, doc_id)
# 3. Retrieval クエリ
retrieval = pi.submit_query(doc_id, user_query)
retrieval_id = retrieval["retrieval_id"]
print("Retrieval submitted:", retrieval_id)
# 4. Retrieval 完了待ち
resp = pi.get_retrieval(retrieval_id)
while resp.get("status") != "completed":
time.sleep(3)
resp = pi.get_retrieval(retrieval_id)
retrieved_nodes = resp.get("retrieved_nodes", [])
print("Retrieved nodes count:", len(retrieved_nodes))
# 5. コンテキスト組み立て
context_texts = []
for node in retrieved_nodes:
title = node.get("title", "")
text = node.get("text", "")
relevant = node.get("relevant_contents", [])
snippet = ""
if relevant and isinstance(relevant, list):
first = relevant[0]
if isinstance(first, dict):
snippet = first.get("relevant_content", "")
else:
snippet = str(first)
context_texts.append(f"### {title}\n{text}\n{snippet}")
# 6. プロンプト作成
prompt = (
"次のドキュメントの情報に基づいて質問に答えてください。\n\n"
+ "\n\n".join(context_texts)
+ "\n\n質問:" + user_query + "\n回答:"
)
# 7. OpenAI Chat API 呼び出し (新しい書き方)
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "あなたは有能な研究アシスタントです。"},
{"role": "user", "content": prompt}
],
max_tokens=500
)
return response.choices[0].message.content
if __name__ == "__main__":
result = vectorless_rag_query(
"PDF_Path",
"最後の試練は?"
)
print("\n=== LLM回答 ===\n")
print(result)
生成結果
Submitted doc: pi-cmfmnmf8e00ch0apezzd4h3g5
Status check: processing
Status check: processing
Status check: processing
Status check: completed
Status check: completed
Retrieval submitted: sr-cmfmnmus6007o0cpeuoncvufh
Retrieved nodes count: 4
=== LLM回答 ===
最後の試練は「孤独の鐘」です。アレンが深い洞窟に入り、暗闇の中で鐘の音を聞く場面が描かれています。
しっかりと最後の試練は「孤独の鐘」と回答することができており簡単な内容も含めて正確に回答できていることがわかります。
最後に
本記事では、PageIndexを用いて OCR→Markdown 化→ツリー化→Retrieval→LLM 応答という最小構成の Vectorless RAG を構築してみました。RAGでの木構造はRAPTORでも採用されていましたが埋め込みやベクトルDBに依存せず、章節という論理構造を索引として辿る設計は斬新な方法であると感じました。
一方でまだまだ新しい技術なのでコーディングミスをしていなくてもエラーが出てしまうこともあり安定していない印象を受けました。
それでも、Embedding を生成せず即座に RAG を構成できるという利便性は非常に魅力的であり、初期段階のナレッジ探索や社内文書の検索支援など、軽量に試したいユースケースでは良いと感じました。今後 API の安定性が高まり、Tree 生成や Retrieval の精度が洗練されていけば、Vectorless RAG はクラウドやベクトルDBを前提としない新しい知識利用基盤の選択肢として定着していく可能性もありそうです。
最後まで読んでいただきありがとうございました。PageIndex を使ったRAG構成に興味がある方の参考になれば嬉しいです。
ご質問やフィードバックがあれば、ぜひコメントで教えてください!
余談
最後に出てきたRAPTORの論文です。
今回は文章をハンズオンを行う形式で書いてみましたが、いつもと書き方が違うためかなり違和感がありました。。。
※今回のハンズオンは公式のドキュメント・ハンズオンを元にしています。