无向量 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 时可能遇到哪些典型问题?

主要集中在摘要质量(影响导航准确性)、分段策略(影响内容完整性)和性能优化(影响响应速度)三个方面。通过调整模型选择、阈值参数和预处理策略,大多数问题可以得到缓解。


实用摘要与操作清单

适用场景

  • 结构化文档(手册、政策、规范、教材)
  • 需要可解释检索路径的合规场景
  • 向量存储成本敏感或基础设施受限的环境
  • 文档更新频率低、可接受一次性索引成本的知识库

快速启动清单

  1. [ ] 准备 Markdown 格式的输入文档
  2. [ ] 安装依赖:pip install openai
  3. [ ] 设置 OpenAI API 密钥环境变量
  4. [ ] 创建项目目录结构和模块文件
  5. [ ] 复制本文提供的代码到对应文件
  6. [ ] 运行 main.py 构建索引(首次)
  7. [ ] 调用 ask() 函数进行查询测试
  8. [ ] 根据结果调整 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 调用)和模型响应速度。对于延迟敏感的场景,可以考虑缓存常见查询路径、使用更快的模型、或预计算常见问题的答案。