进阶召回 — RAPTOR、GraphRAG 与查询分解
30 秒导读: 第 03 章的混合检索(向量 + BM25 + 重排)是「把问句和一堆平铺 chunk 逐个比相似度」。 但有三类问题,平铺 chunk 天生接不住:问「这份年报整体讲了啥」——答案不在任何单个 chunk 里; 问「A 的老板的老板是谁」——要沿关系跳好几步;问「对比 X 和 Y 在成本和性能上的差异」——一个问句里 塞了多个子意图。本章讲 RAGFlow 为这三类分别造的三件结构化召回武器,外加两个「顺藤摸瓜」的辅助召回。
本章是第 03 章 混合检索与融合重排 的进阶篇。通用的打分、融合、重排不再重复
(见第 03 章的 Dealer.retrieval / rerank);这里只讲「结构化 / 多跳」召回策略本身。
1. 第 03 章接不住的三类问题(本章要补的洞)
先说清楚为什么还要有这一章。第 03 章的检索单元是「一段一段平铺的原文 chunk」,召回的本质是 「问句向量 vs chunk 向量的近邻 + 关键词命中」。这套办法有三个结构性盲区:
| 盲区(接不住的问题) | 白话例子 | 为什么平铺 chunk 接不住 |
|---|---|---|
| 跨段主旨型 | 「这份报告整体结论是什么?」 | 主旨是若干段落合起来才浮现的,任何单个 chunk 都只讲局部 |
| 多跳关系型 | 「张三所在公司的竞争对手有谁?」 | 答案要沿实体关系跳 2~3 步,而相似度只看「和问句像不像」 |
| 复合多意图型 | 「对比 A、B 在价格和续航上的优劣」 | 一个问句里几个子问题,单轮检索会被主意图带偏、漏掉次意图 |
RAGFlow 的三件进阶武器,正好一一对应;再加两个结构辅助召回:
进阶召回全景(在第 03 章通用检索之上叠加)
┌─────────────────────── 写入侧(入库时预先构建结构) ───────────────────────┐
│ │
│ RAPTOR 建树 GraphRAG 建图 │
│ 把 chunk 自底向上聚类 从 chunk 抽实体/关系 → 社区发现 → 社区摘要 │
│ 逐层 LLM 摘要 摘要节点也当成 chunk 入库 │
│ │ │ │
└────────┼────────────────────────┼────────────────────────────────────────┘
│ 摘要 chunk │ 实体/关系/社区报告 chunk
▼ (raptor_kwd) ▼ (knowledge_graph_kwd)
┌───────────────────────── 同一个索引库 ──────────────────────────┐
│ 原文 chunk + RAPTOR 摘要 chunk + 图谱 chunk │
└───────────────────────────────────────────────────────────────┘
▲ ▲ ▲
┌────────┼────────────────────────┼──── ────────────────────┼──────────┐
│ │ │ │ │
│ ① RAPTOR 召回 ② GraphRAG 召回(KGSearch) ③ 查询分解 │
│ (透明:摘要就是普通 query_rewrite→实体/关系/ research 递归拆 │
│ chunk,被通用检索 类型召回→n-hop 扩展→ 子问题,逐层检索 │
│ 顺带捞回) 社区报告 汇总 │
│ │
│ ④ 结构辅助:retrieval_by_toc(按目录补章节) / retrieval_by_children │
│ (子块命中→回捞父块) │
└── 读取侧(检索时) ─────────────────────────────────────────────────┘
怎么读这张图: 上半是「入库时」预先造好的结构(树 / 图),它们产出的摘要节点都当普通 chunk 存进同一个 索引;下半是「检索 时」三种策略如何用上这些结构。关键设计:RAPTOR 摘要对检索侧透明——它就是多了一批 更「宏观」的 chunk,通用检索会自然捞到;而 GraphRAG 和查询分解则是独立的召回入口,产出后拼接进结果。
三种主武器 + 两种辅助,一句话各自定位:
| 手段 | 解决哪个盲区 | 核心动作 | 代价 |
|---|---|---|---|
| RAPTOR 层次摘要 | 跨段主旨型 | 入库时聚类 + 逐层摘要,摘要入库 | 入库慢、多花 LLM 摘要 token |
| GraphRAG 图谱召回 | 多跳关系型 | 建图 + 按实体/关系/社区召回 + n-hop | 建图极贵(逐 chunk 抽取 + 社区摘要) |
| 树状查询分解 | 复合多意图型 | 递归拆子问题、逐层检索汇总 | 检索时多轮 LLM,延迟高 |
| retrieval_by_toc | 章节完整性 | 命中后按文档目录补齐相关章节 | 一次额外 LLM 选章 |
| retrieval_by_children | 上下文完整性 | 子块命中 → 回捞父块 | 无(纯索引查) |
2. RAPTOR — 层次摘要树(接住「跨段主旨型」)
2.1 它要解决的小问题
问「整篇讲了什么」,答案散在几十个 chunk 里,没有哪一段能单独命中。RAPTOR (Recursive Abstractive Processing for Tree-Organized Retrieval,递归抽象化的树组织检索)的思路是: 入库时就先把相近的 chunk 聚成簇、让 LLM 写一段簇摘要,再把摘要继续往上聚、再摘要,一层层堆到树顶。 这些摘要节点也作为 chunk 存进索引——于是「宏观主旨」也有了可被向量命中的落点。
2.2 直觉:自底向上盖一座「摘要金字塔」
第 2 层摘要 [ 全文主旨摘要 ] ← 越高越宏观
/ \
第 1 层摘要 [簇A摘要] [簇B摘要] ← LLM 对簇写的摘要
/ \ / \
第 0 层原文 c1 c2 c3 c4 ... ← 原始 chunk(叶子)
底层是原始 chunk;每 一层「聚类 → 摘要」得到更少、更概括的节点;顶层逼近全文主旨。问宏观问题时, 高层摘要节点向量上更接近问句,于是被召回;问细节问题时,底层原文照旧命中。两头都不落空。
2.3 两种建树策略:经典 GMM 树 vs Psi 合并树
RAGFlow 的 RAPTOR 实现支持两种 tree_builder,常量定义在
rag/utils/raptor_utils.py(RAPTOR_TREE_BUILDER = "raptor"、PSI_TREE_BUILDER = "psi"):
| 策略 | 怎么分层 | 适合 |
|---|---|---|
经典 RAPTOR("raptor") | 每层用 UMAP 降维 + GaussianMixture(高斯混合模型)软聚类,一簇一摘要,逐层收敛 | 通用,论文原版 |
Psi 合并树("psi") | 按 chunk 两两余弦相似度排序,并查集自底向上「最相似先合并」建二叉合并树,再按高度分层摘要 | 想要确定性、可控 fanout 的合并结构 |
入口是同一个 __call__,按 tree_builder 分流(rag/raptor.py:649 __call__):
# rag/raptor.py:656 __call__ 里的分流(节选)
if self._tree_builder == PSI_TREE_BUILDER:
return await self._build_psi_layers(chunks, callback, task_id) # Psi 合并树
# 否则走下面的经典 GMM/AHC 循环 ...
2.4 经典树:UMAP 降维 + GMM 聚类,一层层摘上去
经典路径是个 while end - start > 1 的循环,每轮处理「上一层新产出的节点区间」:
# rag/raptor.py:690 经典 RAPTOR 每层的降维 + 聚类(节选)
n_neighbors = int((len(embeddings) - 1) ** 0.8)
reduced_embeddings = umap.UMAP( # 先把高维向量降到 ≤12 维,聚类更稳
n_neighbors=max(2, n_neighbors),
n_components=min(12, len(embeddings) - 2),
metric="cosine",
).fit_transform(embeddings)
# 默认用 GMM:选 BIC 最优的簇数,再软分配
n_clusters = self._get_optimal_clusters(reduced_embeddings, random_state, task_id=task_id)
几个关键设计点:
- 簇数不是拍脑袋定的。
_get_optimal_clusters(rag/raptor.py:235)在1..max_cluster里逐个试 GaussianMixture,挑 BIC(贝叶斯信息准则,越低越好)最小的簇数——自动决定这层该分几簇。 - 软分配 + 阈值。 GMM 给每个点算「属于各簇的概率」,
probs > self._threshold的簇都算数 (rag/raptor.py:720),即一个 chunk 可以进多个簇(默认取第一个)。 - 还有一条 AHC 备选。 若
clustering_method == "ahc",改用 Ward 层次聚类 + 树状图「最大间隙」启发式 自动定簇数(_get_clusters_ahc,rag/raptor.py:256),再用_adjust_tree_nodes按最近质心迭代微调。 - 簇摘要即新 chunk。 每个簇的原文拼起来喂 LLM 出一段摘要,连同它的 embedding 追加进
chunks(_summarize_texts,rag/raptor.py:309;循环里summarize于rag/raptor.py:664把结果 append)。 这层摘完,layers.append((end, len(chunks)))记下这层的区间,下一轮就对这批摘要再聚类。
2.5 Psi 合并树:并查集把「最像的先合并」
Psi 路径不做降维聚类,而是精确算所有 chunk 对的余弦相似度、从高到低排序,用并查集依次合并,建出一棵 自底向上的合并树。核心数据结构是本文件里两个类:
_PsiTreeNode(rag/raptor.py:48):一个树节点,含index / text / embedding / children / parent。_PsiUnionFind(rag/raptor.py:59):带秩的并查集,union(i, j)(rag/raptor.py:115)把两个最相似的 节点合并,并在紧凑的_tree数组里记「子 → 父」边;tree属性(rag/raptor.py:150)导出这张父指针表。
建树主流程(_build_psi_structure,rag/raptor.py:563):
# rag/raptor.py:488 精确 Psi 子树:按相似度排序的对,并查集合并到只剩一棵(节选)
ranked_pairs = self._rank_leaf_pairs(nodes) # 所有叶子对,按余弦相似度降序
union_find = _PsiUnionFind(len(nodes))
for left_idx, right_idx in ranked_pairs:
if union_find.union(int(left_idx), int(right_idx)):
merges += 1
if merges == len(nodes) - 1: # 合并 n-1 次 → 连成一棵树
break
工程上的两个防爆点:
- 大规模分桶。 chunk 太多时「所有对」是 O(n²),
_split_psi_buckets(rag/raptor.py:375)先用类 k-means 把节点切成≤psi_bucket_size的桶,桶内精确合并,再对桶根合并(_build_bucketed_psi_structure,rag/raptor.py:522)。psi_exact_max_leaves/psi_bucket_size控制这两个阈值。 - 重平衡 fanout。 二叉合并树太瘦,
_rebalance_psi_tree(rag/raptor.py:443)把超过max_cluster个孩子的节点分组,保证每个内部节点的孩子数不超标,再逐层摘要(_build_psi_layers→summarize_node,rag/raptor.py:598/606)。