背景 链接到标题

上一篇 对比了四种 PageIndex 检索方案的 Token 消耗,结论是手写 Agent 循环最省 Token,但每次都要自己写编排逻辑。

能不能把 PageIndex 的检索能力直接注册为 Agent 的原生工具,让 LLM 自己决定什么时候查、怎么查?

Opencode 支持自定义工具(TypeScript 插件),于是把 PageIndex 的三个查询能力封装成三个工具,注册到 Agent 的工具列表中。

三个工具 链接到标题

qdrant-search — 语义搜索 链接到标题

给定一个问题,通过 bge-m3 向量化后在 Qdrant 中检索,返回命中的教材、章节、页码和文本片段。用于快速定位"哪本书、哪一页"。

export default tool({
  description: "搜索教材内容(Qdrant 语义搜索),返回命中的教材页面及完整文本",
  args: {
    query: tool.schema.string().describe("搜索内容,如《平面向量的数量积》"),
    top_k: tool.schema.number().default(5),
    book: tool.schema.string().optional().describe("限定单书"),
  },
  async execute(args, context) {
    const script = path.join(root, "bin/qdrant-search")
    const cmd = `${script} "${args.query}" --top-k ${args.top_k || 5}`
    return execSync(cmd, { encoding: "utf-8" }).trim()
  },
})

pageindex-structure — 树结构 链接到标题

给定 book_id,返回该教材的章节树(仅标题、摘要、页范围,不含正文)。轻量快速,用于查看章节结构。

export default tool({
  description: "获取教材的树结构(仅标题、摘要、页范围,不含文本)",
  args: {
    book_id: tool.schema.string().describe("教材标识,如 教材/数学/数学(A版)必修第二册"),
  },
  async execute(args, context) {
    const script = path.join(root, "bin/pageindex-structure")
    const cmd = `${script} --book "${args.book_id}"`
    return execSync(cmd, { encoding: "utf-8" }).trim()
  },
})

pageindex-content — 页内容 链接到标题

给定 book_id 和页范围,返回该范围的完整文本。支持 17-18(范围)、17(单页)、17,19(多段)。

export default tool({
  description: "获取教材指定页的文本内容",
  args: {
    book_id: tool.schema.string().describe("教材标识"),
    pages: tool.schema.string().describe("页范围,如 '17-18'"),
  },
  async execute(args, context) {
    const script = path.join(root, "bin/pageindex-content")
    const cmd = `${script} --book "${args.book_id}" --pages "${args.pages}"`
    return execSync(cmd, { encoding: "utf-8" }).trim()
  },
})

调用链路 链接到标题

每个工具背后是一条完整的调用链:

Agent → Opencode tool (TypeScript) → bash wrapper → Windmill API → Windmill script → MinIO/Qdrant

pageindex-content 为例:

  1. Agent 调用工具,传入 book_idpages
  2. TypeScript 插件执行 bin/pageindex-content --book "教材/数学/数学(A版)必修第二册" --pages "17-18"
  3. bash 脚本向 Windmill API 提交 job
  4. Windmill 执行 f/query/pageindex_content 脚本
  5. 脚本从 MinIO 读取 content_list.json,按 page_idx 分组拼接文本,返回指定页的内容

pageindex-structure 类似,只是读取 pageindex.json 并剥离 text 字段,只返回结构。

qdrant-search 则先调 Ollama bge-m3 做 embedding,再查 Qdrant 的 ebook_chunks collection,从 payload 中直接取单页文本返回。

Agent 工作流 链接到标题

三个工具组合使用,Agent 可以自主完成"搜索→定位→读取"三步:

用户提问:"什么是平面向量的数量积?"

Step 1: qdrant-search("平面向量的数量积")
  → 命中 教材/数学/数学(A版)必修第二册,第17-18页

Step 2: pageindex-structure("教材/数学/数学(A版)必修第二册")
  → 看到第六章 平面向量及其运算 → 6.2 数量积 (page 17-20)

Step 3: pageindex-content("教材/数学/数学(A版)必修第二册", "17-18")
  → 获取原文,基于原文回答

整个流程由 Agent 自主编排,不需要硬编码任何检索逻辑。

数据来源 链接到标题

所有数据存储在 MinIO 的 ebook/parsed/ 目录下:

ebook/parsed/教材/数学/数学(A版)必修第二册/
├── content.md              # MinerU OCR 输出的完整 markdown
├── content_list.json       # 按 page_idx 分组的结构化内容
└── pageindex.json          # PageIndex 树索引(含 LLM 摘要)

pageindex.jsonf/index/pageindex/build_index 脚本生成:解析 markdown 标题层级构建树,通过 DeepSeek 对长节点生成摘要,映射页码范围。

Qdrant 的 ebook_chunks collection 存储的是按页切分的文本向量,用于语义检索。

工程取舍 链接到标题

三个工具返回的都是单页级别的文本,单次调用 prompt 约 1-2K token。区别在于调用方式:

  • qdrant-search:语义匹配,一次返回 top-k 页,适合"不知道在哪本书"的开放搜索
  • pageindex-structure:无正文,仅结构,约 0.5K token,用于定位章节页范围
  • pageindex-content:按页精确获取,适合已知 book_id 和页码范围后的精确读取

Agent 自主编排意味着:开放问题走 qdrant-search,定位到书后走 structure → content 精读。相比固定流程的 RAG 方案,Token 消耗更灵活。

项目代码 链接到标题

https://github.com/MarshalW/pageindex-demo