背景 链接到标题
上一篇 对比了四种 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 为例:
- Agent 调用工具,传入
book_id和pages - TypeScript 插件执行
bin/pageindex-content --book "教材/数学/数学(A版)必修第二册" --pages "17-18" - bash 脚本向 Windmill API 提交 job
- Windmill 执行
f/query/pageindex_content脚本 - 脚本从 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.json 由 f/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 消耗更灵活。