无向量 RAG 系统:用层级索引与推理替代嵌入检索
本文核心问题:如果不使用向量嵌入、不依赖相似度搜索,能否构建一个同样高效的检索增强生成(RAG)系统?
答案是肯定的。通过模拟人类查阅书籍的思维过程——先看目录、再定位章节、最后找到具体段落——我们可以构建一个完全基于层级树状索引和 LLM 推理的检索系统。这种方法不仅规避了传统 RAG 的向量存储成本,还在可解释性和精确控制方面展现出独特优势。
为什么需要”无向量”的替代方案?
传统 RAG 系统依赖向量数据库和嵌入模型,这带来了几个现实挑战:需要维护额外的向量存储基础设施、嵌入模型本身有计算成本、检索过程对开发者而言是个”黑箱”——你无法直观解释为什么某段文本被检索出来。更重要的是,相似度匹配有时会返回语义相关但逻辑上并不合适的上下文。
人类查找信息的方式完全不同。当你在一本教科书中寻找答案时,你不会逐页扫描比较语义相似度,而是利用文档的结构:先看目录定位章节,再看小节标题,最后翻到具体页面。这种基于结构和推理的检索方式,正是 PageIndex 系统的核心设计理念。
本段核心问题:无向量 RAG 与嵌入检索的本质区别是什么?
区别在于前者模拟人类的结构化导航行为,后者依赖数学相似度计算。前者通过 LLM 的推理能力在树状索引中逐级决策,后者通过向量空间中的距离度量进行匹配。两种路径各有适用场景,而本文展示的是一种在特定场景下更可控、更透明的替代方案。
系统架构全景
PageIndex 系统在两个关键阶段工作:索引构建(一次性运行)和查询检索(每次提问运行)。
索引阶段的数据流:
查询阶段的数据流:
本段核心问题:PageIndex 系统的完整工作流程是怎样的?
系统分为五个明确的步骤:首先将文档解析为层级树状结构,然后自底向上为每个节点生成摘要,接着将索引序列化保存,查询时通过 LLM 推理在树中导航定位,最后基于检索到的上下文生成答案。这种流水线设计确保了索引构建的一次性和查询的高效性。
项目结构与初始化
让我们从搭建项目开始。以下是推荐的目录结构:
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
本段核心问题:搭建 PageIndex 项目需要哪些基础文件?
核心模块包括节点定义、文档解析、摘要生成、检索逻辑和存储管理五个部分。这种模块化设计让每个组件职责清晰,便于后续调试和扩展。
核心数据结构:PageNode
每个文档段落被抽象为一个 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
设计考量:为什么将内容和摘要分开存储?
content 字段保存原始文本片段,仅在叶子节点有值;summary 字段则存储 LLM 生成的概述,用于在检索时快速判断相关性。这种分离让系统可以在不加载完整文本的情况下完成导航决策,显著降低查询时的 token 消耗。
本段核心问题:PageNode 如何表示文档的层级结构?
通过 depth 字段标识层级深度,通过 children 列表维护子节点,通过 parent 引用回溯父节点。这种双向链接结构支持自顶向下的导航和自底向上的摘要生成。
文档解析:从线性文本到层级树
解析阶段是系统的关键创新点。我们不使用固定的分块策略,而是让 LLM 根据文档的逻辑结构进行智能分段。
分段策略
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-4",
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
本段核心问题:系统如何将扁平文本转换为层级树结构?
采用两遍解析策略:第一遍将完整文档分割为顶层章节;第二遍对超过 300 词的章节递归细分,生成二级小节。短章节直接作为叶子节点保留原始内容,长章节则成为内部节点,其子节点承载具体内容。这种自适应策略确保既不会过度分割破坏语义连贯性,也不会让单个节点过于庞大。
实际场景示例:假设你正在处理一份 50 页的产品文档,包含”快速入门”、”API 参考”、”故障排查”等章节。其中”API 参考”章节长达 20 页,系统会自动将其细分为”认证”、”端点列表”、”错误码”等子章节。当用户询问”如何获取访问令牌”时,系统能在根节点就判断出应进入”API 参考”分支,再在该节点内定位到”认证”子章节,而非遍历整个文档。
摘要生成:自底向上的内容蒸馏
解析完成后,系统需要为每个节点生成摘要。采用后序遍历(post-order)策略,确保子节点的摘要先于父节点生成。
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-4-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)
本段核心问题:为什么采用后序遍历生成摘要?
父节点的摘要需要概括其所有子节点的内容。只有先为子节点生成摘要,父节点才能基于这些摘要生成更高层次的概述。这种自底向上的方式确保了整个树结构的摘要层次清晰、信息不丢失。
实际场景示例:考虑一个电商政策文档,”配送选项”章节下包含”国内配送”和”国际配送”两个子章节。叶子节点的摘要可能是”国内配送通过 USPS 在 3-5 个工作日内送达”和”国际配送通过 DHL 在 7-14 个工作日内送达”。父节点”配送选项”的摘要则会整合为”涵盖国内(3-5 天)和国际(7-14 天)两种配送方式”。当用户询问”多久能收到货”时,系统通过根节点摘要定位到”配送选项”,再通过子节点摘要精确定位到具体配送类型的详细说明。
索引持久化:一次构建,多次使用
将构建好的树结构序列化为 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))
本段核心问题:为什么需要持久化索引?
对于静态文档或更新频率较低的知识库,索引构建是一次性成本。序列化后的 JSON 文件可以在不同会话、不同服务实例间共享,查询时只需加载而非重新构建,显著降低响应延迟和 API 调用成本。
检索逻辑:LLM 驱动的树导航
这是系统最具特色的部分。查询时,我们不计算向量相似度,而是让 LLM 根据摘要在树中做决策。
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-4-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
本段核心问题:系统如何在无向量检索的情况下定位相关信息?
通过迭代决策过程:在每个内部节点,LLM 阅读所有子节点的标题和摘要,选择最可能包含答案的分支。这个过程一直持续到到达叶子节点,其原始内容即为检索结果。这种方法将检索问题转化为一系列可解释的分类决策,每一步都有明确的理由(基于摘要的相关性判断)。
实际场景示例:用户询问”如何申请退款”。在根节点,LLM 看到三个选项:”退换货政策”(摘要:退款在收到退货后 14 天内处理)、”配送选项”(摘要:涵盖国内和国际配送)、”账户设置”(摘要:创建和验证新账户的说明)。LLM 选择”退换货政策”(选项 1)。如果该节点还有子节点(如”退款条件”、”退款流程”、”处理时间”),LLM 会继续决策直到找到最具体的说明段落。
完整集成:从文档到答案
将各模块整合为完整的应用流程:
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-4",
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"))
本段核心问题:完整的 PageIndex 工作流程是怎样的?
分为两个阶段:构建阶段读取文档、解析层级结构、生成摘要并保存索引;查询阶段加载索引、通过树导航检索相关上下文、基于上下文生成答案。这种分离设计让生产环境中的查询响应快速且成本可控。
索引文件示例
构建完成后,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": []
}
]
}
结构特点:短章节(如”退换货政策”)直接作为深度 1 的叶子节点;长章节(如”配送选项”)成为内部节点,拥有深度 2 的子章节。检索时系统会逐层导航直到命中叶子节点。
反思:设计权衡与实践教训
关于自适应分块的思考
传统 RAG 常使用固定长度的文本分块(如每 512 个 token 一块),这可能导致段落被截断、语义连贯性被破坏。PageIndex 的 LLM 驱动分段虽然增加了初始处理成本,但保留了文档的逻辑结构。在实践中,这种结构感知的分块对长文档(如技术手册、法律文件)尤为重要,因为这类文档的信息密度和层级关系是理解的关键。
关于摘要质量的观察
摘要生成是这个系统的”阿喀琉斯之踵”。如果摘要过于笼统,LLM 在导航时会做出错误选择;如果过于详细,又会增加 token 消耗并可能引入噪声。实践中发现,2-3 句话的摘要长度在信息密度和决策准确性之间取得了较好平衡。此外,在摘要 prompt 中明确指定”不要添加文本外的信息”能有效减少幻觉。
关于模型选择的考量
系统在不同阶段使用了不同模型:文档解析使用较强的模型(如 GPT-4)确保分段准确;摘要生成可以使用较轻量模型(如 GPT-4-mini)降低成本;导航决策同样可以使用轻量模型,因为任务相对简单(多选一)。这种分层策略在保证质量的同时控制了总体成本。
关于可解释性的价值
与向量检索相比,PageIndex 的决策过程完全透明。你可以查看每一步 LLM 看到了哪些摘要、为什么选择了某个分支。这种可解释性在需要审计检索逻辑的场景(如医疗、法律、金融合规)中极具价值——你能确切知道答案来自文档的哪个部分,而非面对一个”相似度分数”的黑箱。
常见问题与排查
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| LLM 持续选择错误分支 | 摘要过于模糊或缺乏区分度 | 使用更强的摘要模型,或在 prompt 中要求更具体的摘要 |
| 分段位置不当,切断重要上下文 | 文档过长或结构复杂 | 增加分段调用的 max_tokens,或预处理将文档拆分为约 3000 词的块 |
| 叶子节点内容过长(超过 1500 tokens) | 阈值设置过高导致细分不足 | 降低 SUBSECTION_THRESHOLD,让更多章节被拆分为子章节 |
| 索引构建时间过长 | 文档过大或 API 响应慢 | 考虑并行处理独立章节,或使用缓存机制 |
| 查询响应慢 | 树深度过大或 LLM 调用频繁 | 优化树结构减少层级,或在导航阶段使用更快的模型 |
本段核心问题:使用 PageIndex 时可能遇到哪些典型问题?
主要集中在摘要质量(影响导航准确性)、分段策略(影响内容完整性)和性能优化(影响响应速度)三个方面。通过调整模型选择、阈值参数和预处理策略,大多数问题可以得到缓解。
实用摘要与操作清单
适用场景:
-
结构化文档(手册、政策、规范、教材) -
需要可解释检索路径的合规场景 -
向量存储成本敏感或基础设施受限的环境 -
文档更新频率低、可接受一次性索引成本的知识库
快速启动清单:
-
[ ] 准备 Markdown 格式的输入文档 -
[ ] 安装依赖: pip install openai -
[ ] 设置 OpenAI API 密钥环境变量 -
[ ] 创建项目目录结构和模块文件 -
[ ] 复制本文提供的代码到对应文件 -
[ ] 运行 main.py构建索引(首次) -
[ ] 调用 ask()函数进行查询测试 -
[ ] 根据结果调整 SUBSECTION_THRESHOLD 和模型参数
一页速览(One-page Summary):
PageIndex 是一种无向量的 RAG 架构,通过 LLM 将文档解析为层级树结构,为每个节点生成摘要,查询时让 LLM 基于摘要逐层导航定位到最相关的叶子节点。核心优势在于可解释性(每一步决策透明)、无需向量基础设施、以及符合人类直觉的检索逻辑。代价是索引构建需要更多 LLM 调用,且检索延迟取决于树深度。最佳适用场景是结构化的长文档和需要审计检索路径的合规应用。
常见问题(FAQ)
Q1: PageIndex 与传统向量 RAG 相比,哪个更好?
没有绝对优劣,取决于场景。PageIndex 在结构化文档、可解释性要求高、或无向量基础设施的场景表现优异;传统向量 RAG 在非结构化文本、大规模文档库、或需要语义模糊匹配的场景更成熟。两者也可结合使用。
Q2: 系统支持多深的树结构?
当前实现支持任意深度,通过 while not node.is_leaf() 循环处理。实践中 2-3 层(根-章-节)已能处理大多数文档,过深的树会增加查询延迟。
Q3: 如何处理非结构化或高度非线性的文档?
PageIndex 最适合有明确层级结构的文档。对于非结构化内容,可能需要预处理(如先让 LLM 提取结构)或考虑混合方案(结合关键词检索作为备选路径)。
Q4: 索引构建的成本如何?
成本取决于文档长度和模型选择。一篇 50 页的文档可能需要数十次 API 调用(分段 + 摘要),适合离线批量处理。查询阶段每次仅需几次调用(取决于树深度),成本相对可控。
Q5: 可以增量更新索引吗?
当前实现为全量重建。对于增量更新,可以设计子树替换策略:定位到变更章节,重新解析该分支,合并回主树。这需要额外的逻辑开发。
Q6: 为什么使用 JSON 而非数据库存储?
JSON 格式便于调试、版本控制和跨平台迁移。对于生产环境的大规模应用,可以适配为数据库存储(如 MongoDB 存储树结构,PostgreSQL 存储节点关系)。
Q7: 导航阶段的 LLM 选择有什么建议?
导航是分类任务(在 N 个选项中选择),相对简单。轻量级模型(如 GPT-4-mini)通常足够,且能显著降低延迟和成本。如果摘要质量不佳导致导航困难,可升级为更强的模型。
Q8: 这个系统适合实时应用吗?
查询延迟取决于树深度(通常 2-4 次 LLM 调用)和模型响应速度。对于延迟敏感的场景,可以考虑缓存常见查询路径、使用更快的模型、或预计算常见问题的答案。
