@fastrag/pageindex
v0.3.1
Published
TypeScript SDK for PageIndex hierarchical document indexing and optional vector enhancement.
Maintainers
Readme
PageIndex TS SDK
VectifyAI/PageIndex 的 TypeScript 重写版
传统向量 RAG 依赖语义相似度而非真正的相关性 — 但相似 ≠ 相关。PageIndex 采用不同的思路:从文档中构建层级树索引(类似"目录"),然后利用 LLM 推理在树上导航检索。无需分块,无需向量数据库 — 像人类专家一样结构化地阅读文档。
本 SDK 是 PageIndex 框架的 TypeScript 实现,面向 Node.js/TypeScript RAG 场景,完全运行时解耦。
特性
- 基于推理的检索 — 从文档构建层级树索引,通过 LLM 驱动的树搜索替代向量相似度匹配
- LLM 驱动的 TOC 检测 — 自动检测目录并选择最优处理模式
- 三种处理模式 —
process_toc_with_page_numbers、process_toc_no_page_numbers、process_no_toc,支持自动降级 - Markdown 转树 —
mdToTree()将 Markdown 转换为结构化树 - 可选向量增强 — 将树索引结果向量化,支持单文档内的精确片段检索,也适用于多文档场景下快速定位相关节点、避免逐一 LLM 树搜索
- 自托管检索层 —
@fastrag/pageindex/retrieval提供文档注册、检索工具、LLM 树搜索和多文档混合检索 - 运行时解耦 — 你自己提供 LLM(
LlmProvider)、PDF 解析器(DocumentParser)、向量数据库(VectorStore/Embedder)
包结构
@fastrag/pageindex ← 文档结构索引 SDK
exports:
. → 核心流程
./types → 共享类型 + 默认配置
./vector → 向量增强
./retrieval → 自托管检索层
runtime deps:
- gpt-tokenizer| 子路径 | 说明 |
| --- | --- |
| @fastrag/pageindex | 文档结构索引主流程 |
| @fastrag/pageindex/types | 共享类型定义与接口契约 |
| @fastrag/pageindex/vector | 向量增强:分块、索引、搜索 |
| @fastrag/pageindex/retrieval | 文档注册、检索工具、树搜索、混合检索 |
环境要求
- Node.js >= 20
- pnpm >= 10
安装
作为依赖安装:
pnpm add @fastrag/pageindex在本仓库本地开发:
pnpm install
pnpm buildexamples/ 里的可运行示例会从 dist/ 读取构建产物,所以新克隆仓库后要先执行 pnpm build。
先选你的第一条上手路径
- 如果你想先做一个完全不依赖 LLM 的 smoke test,先看 功能手册索引 里的
mdToTree()。 - 如果你想先确认
pageIndex()的接线没问题、再去接真实模型,先用下面的 mock provider smoke test,目标是先拿到非空structure。 - 如果你要做向量检索或 retrieval,先把稳定的
PageIndexResult跑出来,再继续看对应手册。 - 如果你想直接跑端到端 retrieval 示例,在本仓库里执行
pnpm build && node examples/agentic-retrieval.mjs。
快速开始
1) 构建文档树索引
import { pageIndex } from '@fastrag/pageindex';
import type { LlmProvider, PageContent } from '@fastrag/pageindex/types';
// pageIndex 是权威的物理页码
const pages: PageContent[] = [
{ pageIndex: 1, text: 'Table of Contents\n1 Introduction ... 1' },
{ pageIndex: 2, text: '1 Introduction\nThis document ...' },
{ pageIndex: 3, text: '2 Methods\nWe propose ...' },
];
const provider = createDemoProvider();
const result = await pageIndex(
pages,
{
docName: 'example.pdf',
addNodeId: true,
addNodeSummary: false,
addDocDescription: false,
},
provider,
);
console.log(result.metadata.processingMode);
console.log(result.structure);
function createDemoProvider(): LlmProvider {
return {
async chat(messages) {
const prompt = messages[messages.length - 1]?.content ?? '';
if (prompt.includes('detect if there is a table of content')) {
return {
content: '{"thinking":"short document","toc_detected":"no"}',
finishReason: 'stop',
};
}
if (prompt.includes('generate\nthe tree structure')) {
return {
content: JSON.stringify([
{
structure: '1',
title: 'Introduction',
physical_index: '<physical_index_1>',
},
{
structure: '2',
title: 'Methods',
physical_index: '<physical_index_3>',
},
]),
finishReason: 'stop',
};
}
if (prompt.includes('check if the given section appears')) {
return {
content: '{"thinking":"found","answer":"yes"}',
finishReason: 'stop',
};
}
if (prompt.includes('starts in the beginning')) {
return {
content: '{"thinking":"yes","start_begin":"yes"}',
finishReason: 'stop',
};
}
return { content: '{}', finishReason: 'stop' };
},
};
}这个 mock provider 只适合第一轮 smoke test,用来确认接线和返回结构没问题。只要你已经能拿到非空 result.structure,就应该把它替换成真实的 OpenAI / Anthropic / 本地模型适配层。
接真实模型时,稳定默认值本身就是 structure-first:addNodeSummary: false、addDocDescription: false。先保持这个默认姿态,只调通树构建本身;真正需要摘要时再显式打开。当前 summary 仍属于显式开启、但不承诺稳定质量的能力,不要把 addNodeSummary: true 当成稳定版契约的一部分。addDocDescription 只会在摘要后处理路径里生成;真正需要 docDescription 时,要同时打开 addNodeSummary: true 和 addDocDescription: true。
页码语义说明:
pageIndex()会把PageContent.pageIndex当作权威物理页码使用。pageIndex必须严格递增且唯一;中间允许有缺口。- 如果你只有按顺序排列的页面文本,也请先组装成
PageContent[],用1..N这样的连续pageIndex调pageIndex()。
2) Markdown 转树
import { mdToTree } from '@fastrag/pageindex';
const tree = mdToTree('# Intro\nhello\n## Background\nmore context', {
thinning: true,
minTokenThreshold: 500,
});3) 向量增强
import { VectorEnhancer, InMemoryAdapter, treeChunker } from '@fastrag/pageindex/vector';
import type { Embedder } from '@fastrag/pageindex/vector';
const store = new InMemoryAdapter();
const embedder: Embedder = {
dimension: 3,
async embed(texts: string[]) {
// 替换为真实 embedding 服务
return texts.map((t) => [t.length, t.length / 2, 1]);
},
};
const enhancer = new VectorEnhancer(
store,
embedder,
treeChunker, // 也可以替换为自定义分片策略
{ chunkMaxTokens: 1000 },
);
// treeChunker 依赖 node.text,因此为默认 chunker 准备结果时要打开 addNodeText: true
const vectorReadyResult = await pageIndex(
pages,
{
docName: 'example.pdf',
addNodeText: true,
addNodeSummary: false,
addDocDescription: false,
},
provider,
);
await enhancer.index(vectorReadyResult);
const hits = await enhancer.search('introduction section');4) 混合检索
import { HybridSearch } from '@fastrag/pageindex/vector';
const hybrid = new HybridSearch(enhancer, {
vectorTopK: 20,
rerankTopK: 5,
});
const results = await hybrid.search('what is the main contribution?');5) 自托管检索
import { pageIndex, LlmClient, createSilentLogger } from '@fastrag/pageindex';
import { DEFAULT_RETRY_CONFIG } from '@fastrag/pageindex/types';
import {
PageIndexClient,
LlmTreeSearcher,
HybridPipeline,
getPageContent,
} from '@fastrag/pageindex/retrieval';
import {
InMemoryAdapter,
VectorEnhancer,
treeChunker,
type Embedder,
} from '@fastrag/pageindex/vector';
const client = new PageIndexClient();
const result = await pageIndex(
pages,
{
docName: 'example.pdf',
addNodeText: true,
addNodeSummary: false,
addDocDescription: false,
},
provider,
);
const docId = await client.addResult(result, { pages });
const embedder: Embedder = {
dimension: 3,
async embed(texts) {
return texts.map((text) => [text.length, text.length / 2, 1]);
},
};
const enhancer = new VectorEnhancer(
new InMemoryAdapter(),
embedder,
treeChunker,
);
await enhancer.index(result, { docId });
const treeSearcher = new LlmTreeSearcher(
new LlmClient(provider, DEFAULT_RETRY_CONFIG, createSilentLogger()),
);
const pipeline = new HybridPipeline(client, enhancer, treeSearcher, {
vectorTopK: 20,
maxCandidateDocs: 3,
});
const retrievalResults = await pipeline.search('introduction');
if (retrievalResults[0]) {
const supportPages = await getPageContent(client, retrievalResults[0].docId, '1-2');
console.log(supportPages);
}核心流程
PageContent[] → TOC 检测 → 模式选择 → 验证/修复/降级 → TreeNode[] → 后处理 → PageIndexResult- 扫描前 N 页检测目录
- 根据 TOC 有无和页码信息选择处理模式
- 验证 TOC 准确性,自动修复或降级到更简单的模式
- 构建
TreeNode[]树结构 - 可选后处理:
nodeId、nodeText、summary、docDescription
向量增强
原版 PageIndex 使用 LLM 树搜索进行检索 — 将整个树结构作为 prompt 发送给 LLM,由 LLM 推理判断哪些节点相关。这种方式效果好,但每次查询都需要调用 LLM。
@fastrag/pageindex/vector 子路径导出提供了另一种方案:将树结构转换为向量 embedding,检索变为相似度搜索 — 查询时无需 LLM,延迟在毫秒级。
| | 索引阶段 | 检索阶段 | | --- | --- | --- | | 树搜索(原版) | LLM 构建树 | 每次查询 LLM 推理遍历树 | | 向量增强(本 SDK) | LLM 构建树 → embedding 入库 | 向量相似度搜索(无需 LLM) |
覆盖两种场景:
- 单文档内 — 将树节点分块为 embedding,无需 LLM 调用即可快速精确定位相关片段
- 跨多文档 — 将多个文档索引到同一个向量库,一次搜索覆盖所有文档;避免对每个文档逐一执行 LLM 树搜索
流程:PageIndexResult → Chunker(默认实现为 treeChunker)→ Embedder(生成向量)→ VectorStore(存储/检索)。treeChunker 当前采用“按段落切分 + 字符近似阈值”(1 token ≈ 4 chars),长段落或没有空行的文本会继续按字符上限切分,并直接读取 node.text,所以如果你准备用默认 chunker 做向量化,应该在 pageIndex() 时打开 addNodeText: true。每个 chunk 保留树结构元数据(docName、可选 docId、nodeId、title、页码范围),检索结果可追溯到原始文档结构。如果你要把向量召回接到 HybridPipeline,请在 enhancer.index(result, { docId }) 时显式传入稳定文档 ID。
自托管检索层
@fastrag/pageindex/retrieval 子路径把索引内核扩展成可复用的本地检索底座:
PageIndexClient负责文档注册和 workspace 持久化getDocument、getDocumentStructure、getPageContent提供 agent 友好的工具面LlmTreeSearcher负责单文档、只基于结构树的树搜索HybridPipeline负责“向量粗筛 + 树搜索精排”
这层能力刻意保持在自托管范围内,不引入托管 HTTP API、OCR、MCP 或 chat 产品层能力。
接口抽象
LlmProvider
interface LlmProvider {
chat(messages: LlmMessage[], options?: LlmOptions): Promise<LlmResponse>;
chatWithSchema?(
messages: LlmMessage[],
schema: JsonSchema,
options?: LlmOptions,
): Promise<LlmResponse>;
}DocumentParser
interface DocumentParser {
parse(input: string | ArrayBuffer | Uint8Array): Promise<PageContent[]>;
}Embedder
interface Embedder {
embed(texts: string[]): Promise<number[][]>;
readonly dimension: number;
}Chunker
type Chunker = (
result: PageIndexResult,
config?: VectorConfig,
context?: VectorIndexContext,
) => Chunk[];treeChunker 是可直接使用的默认策略实现,但 VectorEnhancer 需要显式注入 chunker,因此你可以按模型分词规则替换为自定义分片策略。
配置项
| 字段 | 默认值 | 说明 |
| --- | ---: | --- |
| tocCheckPageNum | 20 | TOC 检测最多扫描页数 |
| maxPageNumEachNode | 10 | 大节点递归拆分页数阈值 |
| maxTokenNumEachNode | 20000 | 大节点递归拆分 token 阈值 |
| addNodeId | true | 生成 4 位节点 ID |
| addNodeSummary | false | 只有在显式开启时才通过 LLM 生成节点摘要;当前不属于稳定质量承诺 |
| addDocDescription | false | 当 addNodeSummary 同时开启时,生成文档一句话描述 |
| addNodeText | false | 在输出中保留节点原文;这是本地文本挂载,不新增 LLM 调用 |
| retryConfig | 指数退避 | LLM 重试策略(maxRetries: 10、initialDelayMs: 1000、maxDelayMs: 30000、backoffMultiplier: 2) |
| onDegradation | undefined | 处理模式降级时的回调 |
| logger | 静默 | 自定义日志注入 |
输出结构
interface PageIndexResult {
docName: string;
docDescription?: string;
structure: TreeNode[];
metadata: {
processingMode: ProcessingMode;
degradations: DegradationEvent[];
};
}Manuals
当前已经实现的功能手册位于 docs/manuals/README.md。
npm tarball 里也会附带这些手册文件(位于 docs/manuals/)以及可运行示例 examples/agentic-retrieval.mjs,这样第三方即使脱离仓库页面也能直接查阅。
延伸阅读
- 功能手册索引 — 面向当前 SDK 已实现能力的操作说明
- 可运行 retrieval 示例 —
PageIndexClient+VectorEnhancer+HybridPipeline端到端 smoke test;本仓库运行前先执行pnpm build - 检索策略探讨 — LLM 树搜索、MCTS、向量检索三种方案的对比,以及面向大规模文档集合的混合架构设计
