RAPTOR 树状检索:从论文到一份能跑的代码,以及它为什么不是银弹

副标题:一篇把 Stanford 的 RAPTOR(arXiv:2401.18059)拆成「原理 + 完整实现 + 参数表 + 工程边界」的长文。如果你读过我上一篇长上下文 vs RAG 的评测,会记得摘要式检索 RAPTOR 把朴素 BM25 甩开将近一倍------这篇就把它怎么做到的,讲到能复现。


0. 引子

上一篇我们聊长上下文 vs RAG 时,反复出现一个结论:RAG 的失败大多在检索,不在生成。最典型的失败模式,是"只见树木不见森林"。

朴素 RAG 的做法是:把文档切成 200--300 字的块,每块独立 embedding,查询时召回最相似的几块,拼进 prompt。问题在于------

  • 一个问题的答案,可能分散在文档的不同位置,单块里没有完整线索;
  • 很多问题是全局性、总结性的("这本书的核心论点是什么?""这 50 篇论文对 X 的结论有什么分歧?"),没有任何单块能回答,必须跨块综合;
  • 而单块检索只会把"字面最像"的碎片捞上来,模型拿到一堆碎片,拼不出全局。

RAPTOR 想解决的,正是这个"碎片化"问题。它的思路很直白:与其让模型去捞碎片,不如我先帮你把碎片逐级 summarize 成不同抽象层次的节点,建成一棵树,检索时直接捞"已经总结好的上层节点"。

下面把它从论文拆到代码。


1. 论文速览

项 内容
标题 RAPTOR: Recursive Abstractive Processing for Tree-Organized Retrieval
作者 Parth Sarthi, Salman Abdullah, Aditi Tuli, Shubh Khanna, Anna Goldie, Christopher D. Manning
机构 Stanford University
提交 2024-01-31(arXiv:2401.18059)
一句话贡献 递归地 embedding、聚类、摘要文本块,自底向上建一棵多抽象层次的树;推理时从树上检索,跨层次整合长篇文档信息

它解决的痛点原文写得很清楚:现有方法大多只从语料里检索短而连续的块,限制了对整篇文档全局语境的理解。RAPTOR 用"递归摘要建树"补上这个缺口。


2. 核心思想

传统 RAG 的索引是"扁平的一堆块"。RAPTOR 的索引是一棵树:

text 复制代码
                    [Root: 整篇文档的全局摘要]
                    /          |          \
        [Layer 2: 主题A摘要]  [主题B摘要]  [主题C摘要]
           /      \              |            /    \
  [Layer1:段1摘要] [段2摘要]  [段3摘要]  [段4摘要] [段5摘要]
       |              |            |            |          |
  [Layer0:原文块] [原文块]    [原文块]    [原文块]   [原文块]
  • Layer 0(叶节点):原始语料切出来的约 100 token 连续文本块,用 SBERT embedding。
  • 上层(Layer 1, 2, ...):把下层的若干节点聚成一簇,用 LLM 生成一段摘要文本,再对摘要 embedding,作为上层节点。
  • 层级越高,语义越抽象(主题级);越底层越细粒度。

递归执行"嵌入 → 聚类 → 摘要",直到某一层小到无法再聚类为止。论文统计常见是 2--3 层 (NarrativeQA 上观测到过 Layer 4,但只占 0.003%)。平均每个父节点约有 5.7--6.8 个子节点(不同数据集在 5.7 到 6.8 之间)。

一个很关键的数字:压缩率 。论文附录给出,平均摘要长度与子节点长度之和的比值约为 0.28,也就是整棵树相对原始文本压缩了约 72%。换句话说,上层节点用 28% 的 token 量承载了全局语义------这正是它能在有限上下文里塞进"全局视角"的原因。


3. 方法逐步拆解

Step 1:切块 + 嵌入(Layer 0)

  • 把文档切成约 100 token 的连续块(注意是连续文本,不是按语义切)。
  • 用 SBERT 的 multi-qa-mpnet-base-cos-v1 对每个块 embedding,并归一化做余弦相似度。

论文用的是 SBERT 而非 OpenAI embedding,这一点常被复现文章搞错。

Step 2:UMAP 降维

  • 高维向量直接做 GMM 聚类会有"距离度量退化"问题(维度高了,点与点距离趋同)。
  • 先用 UMAP (Uniform Manifold Approximation and Projection)流形降维,缓解该问题。论文用 n_neighbors 来平衡局部与全局结构,具体目标维度数论文未固定(复现时常用 5--10 维)。

Step 3:GMM 软聚类

  • 用 高斯混合模型(GMM) 而非硬 k-means,且是软聚类------一个节点可以同时属于多个簇(因为一段文本可能横跨多个主题)。
  • 簇数不提前拍定,而是用 BIC(贝叶斯信息准则)择优,再用 EM 估计参数。
  • 平均簇规模约 5.7--6.8 个节点。

Step 4:LLM 摘要生成上层节点

  • 对每个簇,把这 5--7 个下层节点的文本拼起来,交给 gpt-3.5-turbo 生成一段连贯摘要。
  • 平均摘要长度约 131 token ,子节点约 85.6 token。

Step 5:递归建树

  • 把上一步的摘要当作新的"叶",回到 Step 1 继续 embedding → 聚类 → 摘要,直到簇数降到 1 或达到最大层数。

4. 检索机制:Collapsed Tree(平铺树)

建好树之后,怎么查?论文对比了两种:

  • Tree Traversal(树遍历):从根往下,每层选 top-k,再把选中节点的祖先链拼起来。
  • Collapsed Tree(平铺树) :把整棵树压成"一层",用查询 embedding 和所有节点 算余弦相似度,按相似度排序选 top-k,直到达到 2000 token 上限(约等价于 top-20 节点;UnifiedQA 实验只用 400 token)。

论文结论是 Collapsed Tree 更好------因为它不限定"必须走祖先链",可以跨层自由挑选"最匹配粒度的节点"。一个全局问题可能直接命中 Root,一个细节问题可能直接命中某个 Layer 0 块,互不干扰。

text 复制代码
查询 "这本书关于X的核心论点是什么?"
        │
        ▼  embedding 查询
        │
  对所有节点算余弦相似度(叶+摘要+根,全平铺)
        │
        ▼  按分数排序,取直到 2000 token
        │
   ┌────┴─────┬────────┐
[Root摘要] [Layer2主题B] [Layer1段3摘要]   ← 跨层自由组合
        │
        ▼  拼成上下文喂给生成模型

5. 实验结果

数据集 / 指标 RAPTOR + 生成模型 前 SOTA 提升
QuALITY 测试集(准确率) 82.6%(GPT-4) CoLISA 62.3% 绝对 +20.3%
QuALITY Hard 子集 76.2%(GPT-4) 54.7% 绝对 +21.5%
QASPER(F-1 Match) 55.7%(GPT-4) CoLT5 XL 53.9% / DPR 53.0% / BM25 50.2% +1.8 / +2.7 / +5.5
NarrativeQA METEOR 19.1%(UnifiedQA-3B) 递归摘要 10.6% 近翻倍
NarrativeQA ROUGE-L 30.8% BM25 23.5% / DPR 28.1% +7.3 / +2.7

重点看 QuALITY:这是一个需要复杂多步推理 的阅读理解基准,RAPTOR + GPT-4 把绝对准确率从 62.3% 拉到 82.6%,是论文最亮的招牌。结论很明确------在需要跨文档综合、全局推理的任务上,递归摘要检索显著优于扁平块检索。

顺带呼应上一篇:在另一项评测(arXiv:2501.01880)里,RAPTOR 作为"摘要式检索器"在 QA benchmark 上达到 38.5%,而朴素 BM25 只有 20.4%------两篇论文从不同角度印证了同一件事:检索质量(尤其是能否拿到全局/摘要级信息)决定 RAG 上限。


6. 完整代码实现

下面是一份忠实于论文方法的精简但可运行的实现。依赖:sentence-transformers、umap-learn、scikit-learn、openai。

python 复制代码
# pip install sentence-transformers umap-learn scikit-learn openai
import numpy as np
from sentence_transformers import SentenceTransformer
import umap
from sklearn.mixture import GaussianMixture
from openai import OpenAI

# ---------- 配置(对应论文参数)----------
EMBED_MODEL = "multi-qa-mpnet-base-cos-v1"   # RAPTOR 原论文使用的 SBERT
LLM_MODEL = "gpt-3.5-turbo"                  # 原论文用于生成摘要的模型
CHUNK_WORDS = 100                            # Layer 0 叶节点块大小(近似 token)
UMAP_DIMS = 10                               # UMAP 目标维度(论文未固定,此处示例)
MAX_TREE_TOKENS = 2000                       # 检索拼接上限(论文 ~top-20 节点)
MAX_LEVEL = 3                                # 最大递归层数

client = OpenAI()  # 本地部署时把 OPENAI_BASE_URL 指向 Ollama 即可
embedder = SentenceTransformer(EMBED_MODEL)


def chunk_text(text, chunk_words=CHUNK_WORDS):
    """按词数近似切块(生产环境应按 token 切)。"""
    words = text.split()
    return [" ".join(words[i:i + chunk_words])
            for i in range(0, len(words), chunk_words)]


def embed(texts):
    return embedder.encode(texts, normalize_embeddings=True)


def summarize(member_texts, llm_model=LLM_MODEL):
    """对一个簇的成员文本生成连贯摘要(Step 4)。"""
    joined = "\n\n".join(member_texts)
    resp = client.chat.completions.create(
        model=llm_model,
        temperature=0.0,
        messages=[
            {"role": "system",
             "content": "将以下多段文本压缩成一页连贯摘要,保留所有关键事实、实体与数字。"},
            {"role": "user", "content": joined},
        ],
    )
    return resp.choices[0].message.content


def build_tree(leaf_texts, level=0):
    """递归建树:embedding -> UMAP -> GMM 软聚类 -> LLM 摘要 -> 上层。"""
    if len(leaf_texts) <= 1 or level >= MAX_LEVEL:
        return [{"text": t, "level": level} for t in leaf_texts]

    vecs = embed(leaf_texts)
    reduced = umap.UMAP(n_components=UMAP_DIMS, random_state=42).fit_transform(vecs)

    # BIC 选最优簇数(论文做法)
    best_k, best_bic = 1, -np.inf
    upper = min(20, len(leaf_texts))
    for k in range(2, upper):
        gmm = GaussianMixture(n_components=k, covariance_type="full", random_state=42)
        gmm.fit(reduced)
        bic = gmm.bic(reduced)
        if bic > best_bic:
            best_k, best_bic = k, bic

    gmm = GaussianMixture(n_components=best_k, covariance_type="full", random_state=42)
    labels = gmm.fit_predict(reduced)  # 示例用硬指派;论文是软聚类

    clusters = {}
    for t, c in zip(leaf_texts, labels):
        clusters.setdefault(int(c), []).append(t)

    upper_texts = [summarize(members) for members in clusters.values()]
    return build_tree(upper_texts, level + 1)


def flatten_tree(nodes):
    """收集所有层节点并补齐 embedding(Collapsed Tree 需要)。"""
    out = []
    for n in nodes:
        out.append({"text": n["text"], "level": n["level"],
                    "vec": embed([n["text"]])[0]})
    return out


def retrieve(query, all_nodes, k_tokens=MAX_TREE_TOKENS):
    """Collapsed Tree 检索:与所有节点算余弦,取至 token 上限。"""
    q = embed([query])[0]
    scored = [(float(np.dot(q, n["vec"])), n) for n in all_nodes]
    scored.sort(key=lambda x: -x[0])
    context, used = [], 0
    for sim, n in scored:
        w = len(n["text"].split())
        if used + w > k_tokens:
            break
        context.append((sim, n["text"]))
        used += w
    return context


# ---------- 端到端示例 ----------
if __name__ == "__main__":
    doc = open("long_document.txt", encoding="utf-8").read()
    leaves = chunk_text(doc)
    tree = build_tree(leaves)
    nodes = flatten_tree(tree)
    hits = retrieve("这本书关于 X 的核心论点是什么?", nodes)
    for score, text in hits:
        print(f"[{score:.3f}] {text[:120]}...")

几点说明,避免你踩坑:

  1. 软聚类:论文是 soft assignment(节点可属多簇),示例代码为了简洁用了硬指派 fit_predict。要忠实复现,应保留 GMM 的 predict_proba 并把节点分配到概率超过阈值的多个簇。
  2. UMAP 维度:论文没固定目标维度,示例取 10;维度越低越偏全局结构,越高越保局部,可调。
  3. 摘要模型:论文用 gpt-3.5-turbo。本地部署可换成任意 OpenAI 兼容端点(Ollama 的 qwen2.5 之类),但摘要质量直接影响上层节点质量------这正是后面要谈的边界。
  4. BIC 选 k:在文档很短时可能选到 k=1(无法聚类),递归自然终止,符合论文"递归至无法聚类"。

7. 参数调优表

参数 论文默认/取值 可调范围 调大/调小的影响
叶块大小 ~100 token 50--300 越小越细粒度、节点越多、建树越贵;越大越粗、可能漏细节
UMAP 维度 未固定(示例 10) 5--30 低维偏全局、高维保局部;影响聚类质量
聚类算法 GMM 软聚类 GMM / k-means GMM 允许跨簇、更贴合"一段多主题";k-means 更快更硬
簇数选择 BIC 自动 2--20 决定每层的扇出,影响树高与摘要粒度
最大层数 递归至不可聚类(常见 2--3) 2--4 层越多抽象越高、压缩越强,但 LLM 摘要调用越多
摘要模型 gpt-3.5-turbo 任意 LLM 质量决定上层节点保真度;弱模型摘要会丢信息
检索 token 上限 2000(~top-20) 400--4000 大则上下文更全但更贵;小则更聚焦
embedding 模型 multi-qa-mpnet-base-cos-v1 任意句向量 决定相似度准度;换模型需重建整棵树

8. RAPTOR 的工程代价与适用边界

RAPTOR 在 QuALITY 上 +20.3% 很诱人,但落到工程上,有几个硬约束:

  1. 建树要烧 LLM 调用 。每一层、每一簇都要一次摘要生成。一篇几万 token 的文档,建树就是几十到上百次 LLM 调用。论文在 Apple M1 16GB 上测过 12,500--78,000 token 文档,token 耗费与构建时间线性缩放------文档越长,建树越贵越慢。
  2. 不适合频繁更新的知识库。朴素 RAG 加一篇文档只需 embedding 一个新块;RAPTOR 加一篇可能要重算受影响的上层摘要。对个人知识库这种"天天往里丢 PDF"的场景,建树成本吃不消。
  3. 摘要质量天花板 = 你的 LLM 天花板。论文用 gpt-3.5-turbo 摘要;如果你在本地跑 3B 小模型做摘要,上层节点会丢信息,增益大打折扣。而 LaRA(arXiv:2502.09977)恰恰证明:弱模型用 RAG 暴涨------但那是"检索",不是"LLM 摘要建树"。
  4. 它强在全局/多跳推理,不代表全面赢。在事实定位、对话式查询上,朴素检索甚至更直接。RAPTOR 是"补充全局视角"的工具,不是替换底层检索。

9. CoreNote 为什么没用 RAPTOR,而是用父子分块

下面这段是工程对照,不是广告。所有事实点来自 CoreNote 公开 README,没做扩张。

CoreNote(GitHub: zhangjianxin477/CoreNote,MIT,FastAPI + Docker Compose 自托管)在"怎么避免检索碎片化"这件事上,选了一条和 RAPTOR 不同但更轻的路:

  • 父子分块,而不是递归摘要建树 。CoreNote 把文档切成子块 200--300 字(精准召回)和父块 800--1200 字(overlap 50,保留完整上下文)。检索命中子块后,通过 parent_chunk_id 回取父块喂给模型。效果上,它用"回取上下文"解决了朴素 RAG 的碎片化,但完全不需要 LLM 摘要调用------零额外生成成本。
  • 混合检索补召回,而不是靠摘要层补全局 。CoreNote 用 bge-m3 的稠密 + 稀疏双路 embedding (维度 1024),再接 bge-reranker-v2-m3 重排。这等于在"检索质量"这一环直接加力,弥补了"没有上层摘要节点"可能损失的全局视角。
  • 本地可选,正好服务弱模型人群。CoreNote 不强制云端,OPENAI_BASE_URL 可指向 Ollama 等本地端点,EMBEDDING_LOCAL_ENABLED 开关存在。对个人在本地显卡跑小模型的人来说,父子分块 + 本地 embedding 的零摘要成本,比每次建树烧 LLM 实在得多。

诚实的边界:RAPTOR 在多跳推理、全局归纳(QuALITY +20.3%)上确实更强,CoreNote 没有、也不声称能替代这一点。它的定位是"个人知识库 + 答案可溯源 + 可本地部署"这个交集------在这个交集里,频繁更新、低延迟、低成本比"极致全局推理"更重要,所以父子分块是更稳的选择。

两者也不是水火不容:你可以把 CoreNote 的父块,作为 RAPTOR 的 Layer 0 输入,再往上叠摘要层------这是给"重全局推理需求"用户的进阶组合思路,但默认不开,因为代价是建树成本。


10. 选型小结

把这篇和前两篇串起来:

  • 朴素块检索:快、便宜、但只见树木。
  • 长上下文(LC):短文档、强模型、不惜成本时最省事,但长碎片化文档和预算敏感时不划算。
  • RAPTOR:全局推理、多跳综合任务的最强检索,代价是 LLM 摘要成本与建树复杂度,不适合频繁更新的库。
  • 父子分块 + 混合检索 + 重排:个人知识库场景的务实折中------零摘要成本、可本地、答案可溯源。

没有谁永远赢。你选哪条,取决于"你的文档多久更新一次、用多强的模型、愿不愿意为全局推理烧 LLM 调用"。


参考论文

  1. Sarthi, P., Abdullah, S., Tuli, A., Khanna, S., Goldie, A., Manning, C. D. (2024). RAPTOR: Recursive Abstractive Processing for Tree-Organized Retrieval . Stanford University. arXiv:2401.18059. arxiv.org/abs/2401.18...
  2. Li, X., Cao, Y., Ma, Y., Sun, A. (2024). Long Context vs. RAG for LLMs: An Evaluation and Revisits . arXiv:2501.01880. arxiv.org/abs/2501.01...
  3. Li, K., et al. (2025). LaRA: Benchmarking RAG and Long-Context LLMs --- No Silver Bullet for LC or RAG Routing . arXiv:2502.09977 (ICML 2025). arxiv.org/abs/2502.09...

FAQ

Q:RAPTOR 一定要用 gpt-3.5-turbo 吗? A:不。摘要模型是可替换的,论文用的是 gpt-3.5-turbo。本地可选任意 OpenAI 兼容端点,但摘要质量直接决定上层节点保真度,弱模型会拖累增益。

Q:软聚类和硬聚类差很多吗? A:在"一段文本横跨多主题"时差别明显。论文用 GMM 软聚类允许节点属多簇,示例代码为简洁用了硬指派,忠实复现应保留 predict_proba。

Q:CoreNote 能用 RAPTOR 吗? A:默认不用。CoreNote 用父子分块 + 混合检索 + 重排解决碎片化,零 LLM 摘要成本,更适合频繁更新的个人库。重全局推理需求可手动把父块当 Layer 0 叠 RAPTOR,但默认不开。

Q:为什么检索用 Collapsed Tree 而不是 Tree Traversal? A:论文实测 Collapsed Tree 更好------它跨层自由挑最匹配粒度的节点,不强制走祖先链,全局问题和细节问题互不干扰。

相关推荐
阿里嘎多学长1 小时前
2026-10-14 GitHub 热点项目精选
开发语言·程序员·github·代码托管
zzu123zsw2 小时前
不只是命令行补全:GitHub Copilot SDK 如何把 CLI 智能体引擎嵌入企业工作流?
github·copilot
miofly2 小时前
GitHub 今日推荐|ps2-web-recomp:浏览器里跑 PS2 游戏的静态重编译方案
c++·开源·github
阿里嘎多学长2 小时前
2026-10-13 GitHub 热点项目精选
开发语言·程序员·github·代码托管
miofly4 小时前
GitHub 日榜趋势速报 | 2026-10-15
开源·github
高频因子挖掘机4 小时前
股票 API 返回 HTTP 429 怎么办?Python 量化系统的限流排查与重试设计
后端·github·api
高频因子挖掘机6 小时前
从回测到实盘:量化策略为什么不应该直接依赖行情 API
后端·github·api
wflynn13 小时前
GitHub 日榜趋势速报 | 2026-10-13
开源·github
m4Rk_17 小时前
【论文阅读】Agent 记忆机制(99):PrisMem——从整体分数搜索走向能力驱动的记忆自进化
论文阅读·人工智能·学习·开源·github