背景 链接到标题
PageIndex 是一个无向量(vectorless)、基于推理(reasoning-based)的 RAG 框架。核心思路是将文档解析为树状索引,让 LLM 通过推理来导航定位内容,而不是靠向量相似度搜索。
这次实验的数据源是一本人教版《物理必修第二册》扫描版 PDF。因为纯扫描件没有文字层,先用 MinerU(vlm-engine)OCR 提取了结构化文本,再插入 <!-- page N --> 标记以使页码可追溯。最后用 PageIndex 对这份带页码的 markdown 建立树索引。
基于同一份数据,实现了四种检索方案,对比它们的实现差异和 Token 消耗。
四种方案的设计差异 链接到标题
方案 A:简单查询 链接到标题
两步走,两次独立 LLM 调用:
LLM #1 看树结构 → 返回行号范围
LLM #2 取内容 → 回答
没有验证步骤,LLM 一次定生死。
方案 B:手写 Agent 循环 链接到标题
三步走,三次独立 LLM 调用:
LLM #1 identify_ranges → 返回候选行号列表
LLM #2 evaluate → 判断内容是否包含答案
LLM #3 final_answer → 基于内容给出最终回答
写了 for 循环遍历候选清单,显式 evaluate 确认命中后才回答。
方案 C:OpenAI Agents SDK(降级版) 链接到标题
注册三个函数工具(get_document / get_document_structure / get_page_content),SDK 自动管理工具调用和上下文注入。Agent 内部走了三步:
turn_1 调 get_document_structure → 看到树结构
turn_2 调 get_page_content("551-556") → 看到内容
turn_3 直接回答
需要说明的是:SDK v0.18+ 使用了 OpenAI Responses API(/v1/responses),而 DeepSeek 只提供 Chat Completions 接口。因此 SDK 版需要降级到 v0.0.19,并用 OpenAIChatCompletionsModel 适配。
方案 D:OpenAI Agents SDK + Moon Bridge 链接到标题
前一篇博文介绍了 Moon Bridge:一个 Go 写的协议转换代理,对外暴露 /v1/responses,对内转为 Anthropic Messages API 发给 DeepSeek。
用 Moon Bridge 后,SDK 不需要降级,也不需要 OpenAIChatCompletionsModel,传字符串模型名即可:
os.environ["OPENAI_BASE_URL"] = "http://192.168.0.72:38440/v1"
os.environ["OPENAI_API_KEY"] = "moonbridge"
agent = Agent(
model="deepseek-v4-flash",
...
)
result = Runner.run_sync(agent, QUESTION) # 无需 asyncio
代码量从降级版的 85 行减少到 60 行。
关键区别:evaluate 这一步 链接到标题
方案 B 和方案 C/D 锁定的行号范围是一样的(551-556),加载的也是同一份页面内容。区别在于:
- 方案 B 多了一次 evaluate 调用:对同一份内容再做一次确认,“这段确实包含定义吗?确认了,好,拿去回答。”
- 方案 C/D 跳过了 evaluate:turn_2 看到内容后 LLM 自认为够了,turn_3 直接回答。
evaluate 的作用不是缩小范围,而是安全网——防止 LLM 第一步选错范围,导致最终回答基于错误的内容。
方案 A 连范围都没选对(LLM 选了目录附近的范围,没有命中定义所在的页面),最终页码是模型凭训练知识编的。
Token 消耗实测数据 链接到标题
使用 DeepSeek v4-flash 模型,记录每次 API 调用的 usage 字段。
方案 A:简单查询 链接到标题
| 调用 | prompt | completion | 其中 reasoning |
|---|---|---|---|
| locate_range | 22,713 | 100 | 100 |
| answer | 363 | 179 | 96 |
| 总计 | 23,076 | 279 | 196 |
结果:行号范围选错,页码和回答都不准确。
方案 B:手写 Agent 链接到标题
| 调用 | prompt | completion | 其中 reasoning |
|---|---|---|---|
| identify_ranges | 16,826 | 519 | 513 |
| evaluate | 142 | 101 | 89 |
| final_answer | 127 | 198 | 151 |
| 总计 | 17,095 | 818 | 753 |
结果:行号正确,页码正确(第10页),回答来源教材原文。
方案 C:Agents SDK(降级版) 链接到标题
| 调用 | prompt | completion | 其中 reasoning |
|---|---|---|---|
| turn_1 | 554 | 66 | 20 |
| turn_2 | 21,991 | 109 | 62 |
| turn_3 | 22,210 | 82 | 17 |
| 总计 | 44,755 | 257 | 99 |
结果:行号正确,页码正确,回答来源教材原文。
方案 D:Agents SDK + Moon Bridge 链接到标题
| 调用 | prompt | completion | 其中 reasoning |
|---|---|---|---|
| turn_1 | 618 | 69 | 0* |
| turn_2 | 21,940 | 111 | 0* |
| turn_3 | 22,318 | 261 | 0* |
| 总计 | 44,876 | 441 | *0 |
*Moon Bridge 做协议转换时,DeepSeek 的 reasoning_tokens 未被映射到 Responses API 的对应字段。实际 DeepSeek 端肯定用了 reasoning,只是这个数据没透传回来。
结果:行号正确,页码正确(第10页),回答来源教材原文。Pydantic warnings 不影响功能。
对比分析 链接到标题
prompt completion reasoning 结果
方案 A(简单) 23,076 279 196 错误
方案 B(手写) 17,095 818 753 正确
方案 C(SDK) 44,755 257 99 正确
方案 D(Moon) 44,876 441 0* 正确
几个值得注意的点:
方案 B prompt 最省(17K)。因为三次调用是独立的,每次只传当前所需内容,不重复。evaluate 只传 142 token 的内容片段,final_answer 传 127 token。
方案 C/D prompt 最高(44K)。因为每次 turn 都累积了历史上下文:turn_1 只有 system + 问题(~600),turn_2 带上了树结构结果(~22K),turn_3 又带上了页面内容(~22K)。合起来 44K 是累积的结果。
方案 C/D reasoning 最少(C是99,D未透传)。因为不需要额外的 evaluate 步骤,LLM 在 turn_2 看到内容后自行判断够了,turn_3 直接输出答案。
方案 D completion 偏高(441 vs C的257)。Moon Bridge 的响应格式与 OpenAI 原生 Responses API 存在差异,导致 SDK 解析时产生额外的序列化内容。
工程取舍 链接到标题
四种方案本质上是 Token 成本与工程复杂度的权衡:
简单查询(方案 A):代码量最少,约 50 行。但没有验证步骤,一步定生死。适合预期结果肯定在特定章节内的场景,容错率低。
手写 Agent(方案 B):约 100 行。省 Token(17K prompt),但有额外的 evaluate 调用,completion 偏高。适合对 Token 成本敏感且能接受额外 reasoning 开销的场景。
Agents SDK 降级版(方案 C):约 85 行。prompt 最高(44K 累积),但 reasoning 最少(99),输出流畅。需要在依赖版本上做兼容处理。
Agents SDK + Moon Bridge(方案 D):约 60 行。代码最简洁,用最新 SDK 不用适配。代价是多了一层 Moon Bridge 服务和 completion token 偏高。如果已经在跑 Moon Bridge(给 Codex CLI 用),这个方案零额外成本。
没有免费的午餐:要么多写代码(手写 evaluate 逻辑),要么多付 Token(SDK 历史累积重传),要么多部署一个代理服务(Moon Bridge)。
项目代码 链接到标题
所有代码和数据已公开:
https://github.com/MarshalW/pageindex-demo
包含完整的 PageIndex 源码、物理课本 OCR 数据、四种方案的脚本,以及页号标记插入工具。