Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
c7fe93b
feat: add PageIndex SDK with local/cloud dual-mode support (#207)
KylinMountain Apr 6, 2026
27e671e
Update pyproject.toml: switch to poetry and bump to 0.3.0.dev0
rejojer Apr 8, 2026
f5de9c9
Add dist/ to .gitignore
rejojer Apr 8, 2026
edb2031
fix: poll status=="completed" in cloud add_document (#226)
rejojer Apr 10, 2026
6d29886
chore: bump version to 0.3.0.dev1
rejojer Apr 10, 2026
595895c
feat:compatible with Pageindex SDK (#238)
saccharin98 May 11, 2026
3595956
chore(deps): declare pydantic as a direct dependency
KylinMountain May 12, 2026
cbea31d
chore: remove unused `ext` local in PDF image block handling
KylinMountain May 12, 2026
d7b36aa
feat(collection): scoped query mode and experimental multi-doc warning
KylinMountain May 15, 2026
a47c36a
feat(collection): doc_ids accepts str|list, design cleanups
KylinMountain May 15, 2026
9ad8304
chore: move legacy SDK e2e script into examples/
KylinMountain May 15, 2026
f354eb1
docs: drop Environment variables and Runnable examples subsections fr…
KylinMountain May 15, 2026
2cab6b5
fix(llm): configurable per-call litellm params instead of mutating th…
KylinMountain Jun 25, 2026
cc7e43c
fix: use .get() in get_leaf_nodes to avoid KeyError on leaf nodes (#331)
ChiragB254 Jul 3, 2026
284ce00
fix(cloud): align cloud/local backend contracts and harden the HTTP l…
KylinMountain Jul 7, 2026
6a73279
fix: patch three critical defects from the SDK review
KylinMountain Jul 7, 2026
956147d
fix: resolve five P1 defects from the SDK review
KylinMountain Jul 7, 2026
6c948a3
refactor(index): dedupe the copied indexing pipeline behind deprecati…
KylinMountain Jul 7, 2026
fe36e25
fix: six P2 correctness/robustness cleanups from the SDK review
KylinMountain Jul 7, 2026
b3616f7
fix: dedup race, cwd-relative image paths, unencoded legacy URLs
KylinMountain Jul 7, 2026
b2756c7
refactor(sdk): typed returns, protocol contract, parser layering
KylinMountain Jul 7, 2026
154c483
fix(cloud_api): restore legacy is_retrieval_ready swallow-on-error co…
KylinMountain Jul 7, 2026
4a6a948
style(cloud): annotate best-effort response-close in query_stream cle…
KylinMountain Jul 7, 2026
a139bd5
merge upstream/main into dev; port #188 into the index pipeline
KylinMountain Jul 7, 2026
b64d717
fix: load .env explicitly in pageindex __init__
KylinMountain Jul 7, 2026
890b520
fix(sqlite): make concurrent indexing writes robust (no "database is …
KylinMountain Jul 7, 2026
e539283
fix(index): bound LLM concurrency safely, per-index and leak-free
KylinMountain Jul 8, 2026
2d46d68
fix(index): bound LLM concurrency at the leaf, not per gather call
KylinMountain Jul 8, 2026
2a69c76
fix: exact md page selection + restore CHATGPT_API_KEY alias
KylinMountain Jul 8, 2026
703017e
style: give Protocol stubs docstring bodies
KylinMountain Jul 8, 2026
cf7f5ce
fix: address PR #272 review findings (directly-fixable items)
KylinMountain Jul 8, 2026
89132cb
feat(cli): accept both bare flags and legacy yes/no for --if-add-* args
KylinMountain Jul 8, 2026
8f536cb
fix(index): strip node text in the level_based path too (no default l…
KylinMountain Jul 8, 2026
04cb9cb
fix: address xhigh code-review findings on 2d46d68..8f536cb
KylinMountain Jul 8, 2026
623ce92
ci: publish to PyPI on version tags (#347)
KylinMountain Jul 9, 2026
b9d0219
fix: prompt-injection delimiter escape, legacy config coercion, gathe…
KylinMountain Jul 9, 2026
4e6a135
fix: CMYK image drop, empty-doc crash, page_index shadowing, sqlite h…
KylinMountain Jul 9, 2026
e12495d
build: declare pytest as a dev dependency
KylinMountain Jul 9, 2026
214098f
fix: ceiling semaphore permit leak under cancellation
KylinMountain Jul 9, 2026
e00d360
fix: bound every LLM call with a per-request network timeout
KylinMountain Jul 9, 2026
91e016d
fix: reject trailing newline in collection-name validation
KylinMountain Jul 9, 2026
56fc3bf
fix: return actionable error for malformed page ranges in local tool
KylinMountain Jul 9, 2026
4456b92
fix: preserve image metadata in cloud get_page_content
KylinMountain Jul 9, 2026
4c7d108
fix: forward stream_metadata to the chat API in legacy client
KylinMountain Jul 9, 2026
72f623a
test: de-flake query_stream early-break test
KylinMountain Jul 9, 2026
1dffa76
fix: sync requirements.txt with the SDK's runtime dependencies
KylinMountain Jul 9, 2026
fc401c9
fix: tolerate empty body on legacy delete_document
KylinMountain Jul 9, 2026
56590c6
Fix sync LLM concurrency limit
KylinMountain Jul 9, 2026
d3ea9b9
fix: scoped concurrency semaphore over-release on cancellation
KylinMountain Jul 9, 2026
9ad7c68
fix: degrade to empty result on LLM retry exhaustion instead of aborting
KylinMountain Jul 10, 2026
e18ccde
fix: prompt guidance referenced fields get_document doesn't return
KylinMountain Jul 10, 2026
d97231b
fix: empty-list scope, get_tree bool casing, md fence tracking; dedup…
KylinMountain Jul 10, 2026
9ad5412
fix: resolve concurrency and DoS pitfalls from PR #272 review
KylinMountain Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Publish to PyPI

# Release flow (the git tag IS the version — nothing to bump in the repo):
# 1. git tag -a v0.3.0.dev2 -m "Release 0.3.0.dev2"
# 2. git push origin v0.3.0.dev2
# 3. This workflow derives the version from the tag, injects it into
# pyproject.toml, builds, publishes to PyPI via OIDC trusted publishing
# (no stored secret), and creates a GitHub Release with generated notes.
#
# The tag must be a PEP 440 version with a leading `v`:
# v0.3.0 v0.3.0rc1 v0.3.0.dev2
# PyPI rejects duplicate version uploads, so each tag must be a new version.
# `pip install pageindex` skips dev/pre releases — install one with
# `pip install pageindex==0.3.0.dev2` or `pip install --pre pageindex`.
#
# One-time setup this workflow depends on:
# - PyPI: add a Trusted Publisher on the `pageindex` project pointing at
# repo VectifyAI/PageIndex, workflow `publish.yml`, environment `pypi`.
# - GitHub: create an Environment named `pypi` (Settings -> Environments).

on:
push:
tags:
- "v*"

jobs:
publish:
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # OIDC trusted publishing to PyPI
contents: write # create the GitHub Release
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.12"

- name: Set version from tag and build
run: |
set -euo pipefail
python -m pip install --upgrade build packaging
VERSION="${GITHUB_REF_NAME#v}"
echo "Publishing version: $VERSION"
# Fail early on a malformed tag instead of publishing a junk version.
python -c "from packaging.version import Version; Version('$VERSION')"
# The git tag is the single source of truth; overwrite the static
# placeholder in [tool.poetry] so the built artifacts carry $VERSION.
sed -i "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
grep '^version = ' pyproject.toml
python -m build

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1.14.0

- name: Create GitHub Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
files: dist/*
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,13 @@ __pycache__
.env*
.venv/
logs/
pageindex.egg-info/
dist/
*.db
venv/
uv.lock

# local SDK test-run artifacts (generated by demos; keep tracked example json)
examples/workspace/files/
examples/workspace/*.db
examples/documents/attention.pdf
68 changes: 65 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,65 @@ You can generate PageIndex tree structures with this open-source repo. Or use ou

---

# 🚀 SDK Usage

A unified `PageIndexClient` powers both local self-hosted and cloud-managed modes. Mode is auto-detected by whether you pass an `api_key`.

### Install

```bash
pip install pageindex
```

### Quick start

```python
from pageindex import PageIndexClient

# Local mode — uses your LLM key (e.g. OPENAI_API_KEY in env).
client = PageIndexClient(model="gpt-4o-2024-11-20")

col = client.collection()
doc_id = col.add("path/to/your.pdf")

print(col.query("What is the main contribution?", doc_ids=doc_id))

# Cloud mode — fully managed, no LLM key needed:
# client = PageIndexClient(api_key="your-pageindex-api-key")
```

`col.query(...)` returns the answer string by default. Always pass `doc_ids` for reliable single-document QA — omitting it queries the entire collection, which is experimental (see below).

### Streaming queries

```python
import asyncio

async def main():
async for ev in col.query("Explain multi-head attention", doc_ids=doc_id, stream=True):
if ev.type == "answer_delta":
print(ev.data, end="", flush=True)
elif ev.type == "tool_call":
print(f"\n[tool] {ev.data['name']}")

asyncio.run(main())
```

`ev.type` is one of: `tool_call`, `tool_result`, `answer_delta`, `answer_done`.

### Multi-document collections (experimental)

Passing `doc_ids` scopes the query to a specific subset of documents — this is the recommended path. `doc_ids` accepts a single id (`str`) or a list:

```python
col.query("What does this paper say?", doc_ids=doc1) # single
col.query("Compare these two papers", doc_ids=[doc1, doc2]) # multi
```

Omitting `doc_ids` queries the **entire collection** and lets the agent pick which docs to read. This is an **experimental** feature with a naive first implementation — we're actively working on better cross-document retrieval. A `UserWarning` is emitted; set `PAGEINDEX_EXPERIMENTAL_MULTIDOC=1` to silence it.

---

# ⚙️ Package Usage

> **Note:** This package uses standard PDF parsing. For use cases with complex PDFs, our [cloud service](https://pageindex.ai/developer) (via MCP and API) offers enhanced OCR, tree building, and retrieval.
Expand Down Expand Up @@ -179,10 +238,13 @@ You can customize the processing with additional optional arguments:
--toc-check-pages Pages to check for table of contents (default: 20)
--max-pages-per-node Max pages per node (default: 10)
--max-tokens-per-node Max tokens per node (default: 20000)
--if-add-node-id Add node ID (yes/no, default: yes)
--if-add-node-summary Add node summary (yes/no, default: yes)
--if-add-doc-description Add doc description (yes/no, default: yes)
--if-add-node-id Add node IDs (on by default; disable with: --if-add-node-id no)
--if-add-node-summary Add node summaries (on by default; disable with: --if-add-node-summary no)
--if-add-doc-description Add a document description (on by default; disable with: --if-add-doc-description no)
--if-add-node-text Add raw text to nodes (off by default; enable with: --if-add-node-text)
```
These flags take no value by default (a bare `--if-add-node-id` turns it on); the
legacy `--if-add-node-id no` form still works for turning an option off.
</details>

<details>
Expand Down
96 changes: 56 additions & 40 deletions examples/agentic_vectorless_rag_demo.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
"""
Agentic Vectorless RAG with PageIndex - Demo
Agentic Vectorless RAG with PageIndex Demo

A simple example of building a document QA agent with self-hosted PageIndex
and the OpenAI Agents SDK. Instead of vector similarity search and chunking,
PageIndex builds a hierarchical tree index and uses agentic LLM reasoning for
human-like, context-aware retrieval.
Build a document-QA agent with self-hosted PageIndex and the OpenAI Agents SDK.
Instead of vector similarity search and chunking, PageIndex builds a
hierarchical tree index and lets an agent reason over it for human-like,
context-aware retrieval.

This demo wires up your OWN agent + tools against the PageIndex Collection API.
For the batteries-included version, just use ``col.query(..., stream=True)`` —
see local_demo.py.

Agent tools:
- get_document() — document metadata (status, page count, etc.)
- get_document_structure() — tree structure index of a document
- get_page_content() — retrieve text content of specific pages
- get_document() — document metadata (name, type, description)
- get_document_structure() — the document's tree-structure index
- get_page_content() — text of specific pages / line ranges

Steps:
1 — Index a PDF and view its tree structure index
1 — Index a PDF and view its tree structure
2 — View document metadata
3 — Ask a question (agent reasons over the index and auto-calls tools)

Requirements: pip install openai-agents
Requirements:
pip install pageindex openai-agents
export OPENAI_API_KEY=your-api-key # or any LiteLLM-supported provider
"""
import sys
import json
Expand All @@ -32,27 +38,37 @@
from agents.stream_events import RawResponsesStreamEvent, RunItemStreamEvent
from openai.types.responses import ResponseTextDeltaEvent, ResponseReasoningSummaryTextDeltaEvent

from pageindex import PageIndexClient
import pageindex.utils as utils
from pageindex import LocalClient

PDF_URL = "https://arxiv.org/pdf/2603.15031"
PDF_URL = "https://arxiv.org/pdf/1706.03762.pdf"

_EXAMPLES_DIR = Path(__file__).parent
PDF_PATH = _EXAMPLES_DIR / "documents" / "attention-residuals.pdf"
PDF_PATH = _EXAMPLES_DIR / "documents" / "attention.pdf"
WORKSPACE = _EXAMPLES_DIR / "workspace"
MODEL = "gpt-4o-2024-11-20" # any LiteLLM-supported model

AGENT_SYSTEM_PROMPT = """
You are PageIndex, a document QA assistant.
TOOL USE:
- Call get_document() first to confirm status and page/line count.
- Call get_document() first to confirm the document's name and type.
- Call get_document_structure() to identify relevant page ranges.
- Call get_page_content(pages="5-7") with tight ranges; never fetch the whole document.
- Before each tool call, output one short sentence explaining the reason.
Answer based only on tool output. Be concise.
"""


def query_agent(client: PageIndexClient, doc_id: str, prompt: str, verbose: bool = False) -> str:
def _normalize_model_for_agents_sdk(model: str) -> str:
"""The OpenAI Agents SDK only recognizes 'openai/' and 'litellm/' model
prefixes; route any other LiteLLM-style provider path (e.g. 'anthropic/...')
through litellm explicitly, mirroring what PageIndex itself does internally
for its built-in agent."""
if model and "/" in model and not model.startswith(("litellm/", "openai/")):
return f"litellm/{model}"
return model


def query_agent(col, doc_id: str, prompt: str, model: str, verbose: bool = False) -> str:
"""Run a document QA agent using the OpenAI Agents SDK.

Streams text output token-by-token and returns the full answer string.
Expand All @@ -61,13 +77,15 @@ def query_agent(client: PageIndexClient, doc_id: str, prompt: str, verbose: bool

@function_tool
def get_document() -> str:
"""Get document metadata: status, page count, name, and description."""
return client.get_document(doc_id)
"""Get document metadata: name, type, and description."""
doc = col.get_document(doc_id)
doc.pop("structure", None) # keep tool output small for the LLM context
return json.dumps(doc, ensure_ascii=False)

@function_tool
def get_document_structure() -> str:
"""Get the document's full tree structure (without text) to find relevant sections."""
return client.get_document_structure(doc_id)
return json.dumps(col.get_document_structure(doc_id), ensure_ascii=False)

@function_tool
def get_page_content(pages: str) -> str:
Expand All @@ -76,13 +94,13 @@ def get_page_content(pages: str) -> str:
Use tight ranges: e.g. '5-7' for pages 5 to 7, '3,8' for pages 3 and 8, '12' for page 12.
For Markdown documents, use line numbers from the structure's line_num field.
"""
return client.get_page_content(doc_id, pages)
return json.dumps(col.get_page_content(doc_id, pages), ensure_ascii=False)

agent = Agent(
name="PageIndex",
instructions=AGENT_SYSTEM_PROMPT,
tools=[get_document, get_document_structure, get_page_content],
model=client.retrieve_model,
model=_normalize_model_for_agents_sdk(model),
# model_settings=ModelSettings(reasoning={"effort": "low", "summary": "auto"}), # Uncomment to enable reasoning
)

Expand Down Expand Up @@ -128,12 +146,14 @@ async def _run():
print()
return "" if not streamed_run.final_output else str(streamed_run.final_output)

# Only the detection is guarded, not the run, so a real error inside _run
# isn't misread as "no running loop".
try:
asyncio.get_running_loop()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, _run()).result()
except RuntimeError:
return asyncio.run(_run())
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, _run()).result()


if __name__ == "__main__":
Expand All @@ -152,37 +172,33 @@ async def _run():
f.write(chunk)
print("Download complete.\n")

# Setup
client = PageIndexClient(workspace=WORKSPACE)
# Setup: self-hosted local client + a collection
client = LocalClient(model=MODEL, storage_path=str(WORKSPACE))
col = client.collection("agentic-demo")

# Step 1: Index PDF and view tree structure
print("=" * 60)
print("Step 1: Index PDF and view tree structure")
print("=" * 60)
doc_id = next(
(did for did, doc in client.documents.items() if doc.get('doc_name') == PDF_PATH.name),
None,
)
if doc_id:
print(f"\nLoaded cached doc_id: {doc_id}")
else:
doc_id = client.index(PDF_PATH)
print(f"\nIndexed. doc_id: {doc_id}")
# Content-hash dedup: re-running reuses the existing doc_id, no re-index.
doc_id = col.add(str(PDF_PATH))
print(f"\ndoc_id: {doc_id}")
print("\nTree Structure (top-level sections):")
structure = json.loads(client.get_document_structure(doc_id))
utils.print_tree(structure)
for node in col.get_document_structure(doc_id):
print(f" - {node.get('title', '(untitled)')}")

# Step 2: View document metadata
print("\n" + "=" * 60)
print("Step 2: View document metadata")
print("=" * 60)
doc_metadata = client.get_document(doc_id)
print(f"\n{doc_metadata}")
meta = col.get_document(doc_id)
meta.pop("structure", None)
print("\n" + json.dumps(meta, ensure_ascii=False, indent=2))

# Step 3: Agent Query
print("\n" + "=" * 60)
print("Step 3: Agent Query (auto tool-use)")
print("=" * 60)
question = "Explain Attention Residuals in simple language."
question = "Explain the Transformer's self-attention in simple language."
print(f"\nQuestion: '{question}'")
query_agent(client, doc_id, question, verbose=True)
query_agent(col, doc_id, question, MODEL, verbose=True)
Loading
Loading