本文将构建一个无向量的、基于推理的 RAG 系统,使用分层页面索引。文档被转换为树结构,LLM 在树中推理以找到答案。无需嵌入。无需相似性搜索。
这与我们在现实生活中搜索信息的方式非常相似。当你想在教科书中查找某些内容时,你不会从头开始阅读每一页。你会打开目录,找到正确的章节,查看其中的小节,然后直接跳到你需要的部分。
PageIndex 的工作方式相同。你给它一个文档,它从该文档构建一棵树,其中每个分支是一个章节,每个叶子是实际文本。然后当你提问时,LLM 逐层导航这棵树以找到正确答案。
完整代码:https://github.com/vixhal-baraiya/pageindex-rag
整体计划
在编写任何代码之前,这是完整的计划:
步骤 1:将文档解析为层次树
我们将文档发送给 LLM,要求它将文本拆分为顶级章节。然后对于每个足够长、可以进一步拆分的章节,我们再次发送给 LLM 获取子章节。这为我们提供了一个多层树结构。短章节保持为叶子节点。长章节成为带有子节点的内部节点。
步骤 2:自底向上总结每个节点
我们从叶子到根遍历树。每个叶子节点获得其原始文本的简短 LLM 生成摘要。每个内部节点从其子节点的摘要构建摘要。根节点最终获得整个文档的摘要。
步骤 3:保存索引
我们将树序列化为 JSON 文件。这就是索引。我们构建一次并重复使用。
步骤 4:通过遍历树进行检索
在查询时,我们从根开始。我们向 LLM 显示所有子节点的摘要,询问应该进入哪个子节点。我们移动到该子节点。重复此过程直到到达叶子。叶子的原始文本就是我们检索到的上下文。
步骤 5:生成答案
我们将检索到的上下文和问题传递给 LLM 并获得答案。
架构
让我们看看数据如何在系统中流动。
索引时(运行一次)

查询时(每个问题运行一次)

现在我们知道了要构建什么以及各部分如何组合,让我们开始编写代码。
步骤 1:设置项目
pageindex-rag/
pageindex/
__init__.py
node.py
parser.py
indexer.py
retriever.py
storage.py
main.py
document.md
创建项目:
mkdir pageindex-rag
cd pageindex-rag
mkdir pageindex
touch pageindex/__init__.py
步骤 2:定义节点(pageindex/node.py)
文档的每个部分都成为一个 PageNode。它存储标题、原始文本、稍后生成的摘要以及其子节点。
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class PageNode:
title: str
content: str # 原始文本,在叶子节点填充
summary: str # 由 LLM 生成,由索引器填充
depth: int # 0 = 根, 1 = 章节, 2 = 子章节
children: list = field(default_factory=list)
parent: Optional["PageNode"] = None
def is_leaf(self) -> bool:
return len(self.children) == 0
步骤 3:解析文档(pageindex/parser.py)
我们通过两遍构建树。首先,我们要求 LLM 将整个文档拆分为顶级章节。然后,对于任何足够长、值得进一步拆分的章节(超过 300 个单词),我们将其发送回 LLM 获取子章节。短章节保持为叶子节点。长章节成为带有子节点的内部节点。
_segment 是执行一级拆分的辅助函数。parse_document 调用它两次:一次用于整个文档,一次用于每个长章节。
import json
import openai
from .node import PageNode
client = openai.OpenAI()
SUBSECTION_THRESHOLD = 300 # 单词数
def _segment(text: str) -> list:
prompt = f"""Split the following text into logical sections.
Return a JSON object with a "sections" key. Each item has:
- "title": short title (5 words or less)
- "content": the text belonging to this section
Text:
{text[:8000]}"""
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
max_completion_tokens=3000,
response_format={"type": "json_object"},
)
parsed = json.loads(response.choices[0].message.content)
return parsed.get("sections", [])
def parse_document(text: str) -> PageNode:
root = PageNode(title="root", content="", summary="", depth=0)
for item in _segment(text):
title = item.get("title", "Section")
content = item.get("content", "")
node = PageNode(title=title, content="", summary="", depth=1)
node.parent = root
word_count = len(content.split())
if word_count > SUBSECTION_THRESHOLD:
subsections = _segment(content)
if len(subsections) > 1:
for sub in subsections:
child = PageNode(
title=sub.get("title", "Subsection"),
content=sub.get("content", ""),
summary="",
depth=2,
)
child.parent = node
node.children.append(child)
else:
node.content = content # 拆分没有产生有用结果,保持为叶子
else:
node.content = content # 足够短,保持为叶子
root.children.append(node)
return root
完成后,短章节是带有内容的叶子节点。长章节是带有子章节子节点的内部节点。此时所有摘要字段都为空。索引器接下来会填充这些。
步骤 4:构建摘要(pageindex/indexer.py)
我们以后序(子节点先于父节点)遍历树。每个叶子总结自己的内容。每个内部节点(如根节点,或任何有子章节的章节)从其子节点的摘要构建摘要。后序保证每个子节点在父节点需要之前都有摘要。
import openai
from .node import PageNode
client = openai.OpenAI()
def _summarize(text: str, section_name: str = "") -> str:
hint = f"This is the section titled: {section_name}./n" if section_name else ""
prompt = f"""{hint}Summarize the following in 2-3 sentences. Be specific and factual. Do not add anything not in the text.
{text[:3000]}"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
max_completion_tokens=150,
)
return response.choices[0].message.content.strip()
def build_summaries(node: PageNode):
# 后序:子节点优先
for child in node.children:
build_summaries(child)
if node.is_leaf():
if node.content.strip():
node.summary = _summarize(node.content, node.title)
else:
node.summary = "(empty section)"
else:
# 从子节点的摘要构建父节点摘要
children_text = "/n/n".join(
f"[{c.title}]: {c.summary}" for c in node.children
)
node.summary = _summarize(children_text, node.title)
运行 build_summaries(root) 后,树中的每个节点都有有意义的摘要。
步骤 5:保存和加载索引(pageindex/storage.py)
我们将树序列化为 JSON,这样我们只构建一次。
import json
from .node import PageNode
def save(node: PageNode, path: str):
def to_dict(n: PageNode) -> dict:
return {
"title": n.title,
"content": n.content,
"summary": n.summary,
"depth": n.depth,
"children": [to_dict(c) for c in n.children],
}
with open(path, "w") as f:
json.dump(to_dict(node), f, indent=2)
def load(path: str) -> PageNode:
def from_dict(d: dict) -> PageNode:
node = PageNode(
title=d["title"],
content=d["content"],
summary=d["summary"],
depth=d["depth"],
)
for child_dict in d["children"]:
child = from_dict(child_dict)
child.parent = node
node.children.append(child)
return node
with open(path) as f:
return from_dict(json.load(f))
步骤 6:通过树搜索检索(pageindex/retriever.py)
从根开始,LLM 读取子节点的摘要并选择最佳分支。如果该子节点是内部节点(它有子章节),我们在这个级别重复。我们继续直到到达叶子。while 循环处理任何深度。
import openai
from .node import PageNode
client = openai.OpenAI()
def _pick_child(query: str, node: PageNode) -> PageNode:
options = "/n".join(
f"{i + 1}. [{c.title}]: {c.summary}"
for i, c in enumerate(node.children)
)
prompt = f"""You are navigating a document tree to find the answer to a question.
Current section: "{node.title}"
Question: {query}
Children of this section:
{options}
Which child section most likely contains the answer? Reply with only the number."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
max_completion_tokens=5,
)
try:
index = int(response.choices[0].message.content.strip()) - 1
return node.children[index]
except (ValueError, IndexError):
return node.children[0]
def retrieve(query: str, root: PageNode) -> str:
node = root
while not node.is_leaf():
if not node.children:
break
node = _pick_child(query, node)
return node.content
步骤 7:整合在一起(main.py)
import os
from pageindex.parser import parse_document
from pageindex.indexer import build_summaries
from pageindex.retriever import retrieve
from pageindex import storage
import openai
client = openai.OpenAI()
INDEX_PATH = "index.json"
def build_index(doc_path: str):
print("Parsing document...")
text = open(doc_path).read()
tree = parse_document(text)
print("Building summaries (this makes LLM calls)...")
build_summaries(tree)
print(f"Saving index to {INDEX_PATH}")
storage.save(tree, INDEX_PATH)
return tree
def ask(query: str) -> str:
if not os.path.exists(INDEX_PATH):
raise FileNotFoundError("Index not found. Run build_index() first.")
tree = storage.load(INDEX_PATH)
context = retrieve(query, tree)
response = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": f"Answer using only the context below./n/nContext:/n{context}/n/nQuestion: {query}"
}],
max_completion_tokens=500,
)
return response.choices[0].message.content.strip()
if __name__ == "__main__":
# 首次运行:构建索引
build_index("document.md")
# 然后查询
print(ask("Your Question"))
索引示例
运行 build_index 后,打开 index.json,你会看到类似这样的内容:
{
"title": "root",
"summary": "Document covers returns, shipping options, and account setup.",
"content": "",
"depth": 0,
"children": [
{
"title": "Returns and Refunds",
"summary": "Refunds are processed within 14 days of receiving the returned item.",
"content": "We accept returns within 30 days...",
"depth": 1,
"children": []
},
{
"title": "Shipping Options",
"summary": "Covers domestic (3-5 days) and international shipping (7-14 days).",
"content": "",
"depth": 1,
"children": [
{
"title": "Domestic Shipping",
"summary": "Standard delivery takes 3-5 business days via USPS.",
"content": "We ship domestically via USPS...",
"depth": 2,
"children": []
},
{
"title": "International Shipping",
"summary": "International orders ship via DHL and arrive in 7-14 days.",
"content": "International shipping is available to 50+ countries...",
"depth": 2,
"children": []
}
]
},
{
"title": "Account Setup",
"summary": "Instructions for creating and verifying a new account.",
"content": "To create an account, visit...",
"depth": 1,
"children": []
}
]
}
短章节保持为 depth-1 的叶子节点。长章节(如「Shipping Options」)成为带有 depth 2 子章节的内部节点。检索逐层导航直到到达叶子。
常见问题
| 问题 | 解决方案 |
|---|---|
LLM 总是选错分支 | 摘要太模糊。在 _summarize 中尝试更强的模型,或在提示词中添加更多细节 |
LLM 分段在不好的位置切断章节 | 当 parse_document 处理长文档时,有时会在思考中途拆分。通过增加分段调用中的 max_tokens 来修复,或者在发送之前将文档拆分为约 3000 个单词的块 |
| 叶子内容很长 | 如果叶子的内容超过约 1500 个 token,降低 SUBSECTION_THRESHOLD,以便更多章节被拆分为子章节 |
总结
这个无向量 RAG 系统的核心思想是:
-
1. 分层索引:将文档构建为树结构,模拟人类查找信息的方式
-
2.
LLM推理:使用LLM逐层导航树结构找到相关内容 -
3. 自底向上摘要:从叶子到根构建摘要,确保每个节点都有有意义的描述
-
4. 一次构建,多次使用:索引只需构建一次,可持久化存储
与传统向量 RAG 相比,这种方法不需要嵌入模型和向量数据库,更适合结构化文档的精确检索。
这里给大家精心整理了一份全面的AI大模型学习资源,包括:AI大模型全套学习路线图(从入门到实战)、精品AI大模型学习书籍手册、视频教程、实战学习、面试题等,资料免费分享!
👇👇扫码免费领取全部内容👇👇

1. 成长路线图&学习规划
要学习一门新的技术,作为新手一定要先学习成长路线图,方向不对,努力白费。
这里,我们为新手和想要进一步提升的专业人士准备了一份详细的学习成长路线图和规划。可以说是最科学最系统的学习成长路线。

2. 大模型经典PDF书籍
书籍和学习文档资料是学习大模型过程中必不可少的,我们精选了一系列深入探讨大模型技术的书籍和学习文档,它们由领域内的顶尖专家撰写,内容全面、深入、详尽,为你学习大模型提供坚实的理论基础。(书籍含电子版PDF)

3. 大模型视频教程
对于很多自学或者没有基础的同学来说,书籍这些纯文字类的学习教材会觉得比较晦涩难以理解,因此,我们提供了丰富的大模型视频教程,以动态、形象的方式展示技术概念,帮助你更快、更轻松地掌握核心知识。

4. 2026行业报告
行业分析主要包括对不同行业的现状、趋势、问题、机会等进行系统地调研和评估,以了解哪些行业更适合引入大模型的技术和应用,以及在哪些方面可以发挥大模型的优势。

5. 大模型项目实战
学以致用 ,当你的理论知识积累到一定程度,就需要通过项目实战,在实际操作中检验和巩固你所学到的知识,同时为你找工作和职业发展打下坚实的基础。

6. 大模型面试题
面试不仅是技术的较量,更需要充分的准备。
在你已经掌握了大模型技术之后,就需要开始准备面试,我们将提供精心整理的大模型面试题库,涵盖当前面试中可能遇到的各种技术问题,让你在面试中游刃有余。

7. 资料领取:全套内容免费抱走,学 AI 不用再找第二份
不管你是 0 基础想入门 AI 大模型,还是有基础想冲刺大厂、了解行业趋势,这份资料都能满足你!
现在只需按照提示操作,就能免费领取:
👇👇扫码免费领取全部内容👇👇

2701



被折叠的 条评论
为什么被折叠?



