万字长文吃透 RAG 索引优化:从「朴素切块翻车」到「工业级检索栈」的三大路线实战

万字长文吃透 RAG 索引优化:从「朴素切块翻车」到「工业级检索栈」的三大路线实战

写在前面:本文是作者在学习 RAG(Retrieval-Augmented Generation)过程中踩过无数坑之后,针对索引优化(Indexing)这一环做的系统性总结。区别于网上零散的概念科普,本文从「朴素 RAG 为什么翻车」这个真实痛点切入,按「问题溯源 → 思想破局 → 三大路线 → 工程栈式组合 → 踩坑实录」的脉络展开,配合可运行的代码片段、ASCII 流程图、对比实测数据,力求做到「读完就能用」。

全文约 1.5 万字,建议收藏后慢慢看。如果有收获,文末点赞+关注就是对我最大的鼓励。


目录


一、问题溯源:朴素 RAG 到底在哪里翻车

如果你做过 RAG 项目,下面的场景大概率都遇到过:

1.1 三个真实场景的翻车现场

场景 1:答案「东一榔头西一棒子」

用户问:「游戏里悟空有哪些形态变化?」,期望 LLM 答出「金刚形态 + 魔佛形态」。但朴素 RAG 的回答只有「魔佛形态」——因为描述两种形态的内容被切散到了不同的 chunk,向检只召回了一部分。

场景 2:表格数据「读错列」

用户问:「2023 年世界首富的净资产是多少?」,朴素 RAG 把整张富豪榜塞 prompt 给 LLM,LLM 居然把 2022 年的数字读出来了——长表格 LLM 容易读串行。

场景 3:专有名词「召回不到」

用户问:「金刚形态的特点」,朴素 RAG 用向量检索——「金刚形态」是个专有名词,向量编码后被「形态」这个通用词稀释,反而召回了讲「孙悟空七十二变」的无关段落。

1.2 根本矛盾:chunk 大小的两难

这三个翻车现场,根因都指向同一个矛盾——chunk 大小怎么选都不对:

chunk 越大  ──→  embedding 语义被稀释,检索精度下降
chunk 越小  ──→  单 chunk 信息不全,LLM 答案断章取义

举个具体例子:一段描述「齐天大圣」的 2000 字原文,按 1000 字符切成两块:

chunk内容主旨向量偏移方向
第一块出身+学艺偏「神话色彩」
第二块金箍棒+战斗偏「兵器战斗」

用户问「孙悟空是谁?」——两个 chunk 都不够「完整」,向量相似度都不高,检索容易偏向某一边。

1.3 为什么调 chunk_size 解决不了

很多新手的第一反应是「调 chunk_size」——但你会发现:

chunk_size检索准确度上下文完整性
大(1000+)差(向量平均化)
小(200–)好(语义集中)差(碎片化)
中(500 左右)

调参永远在「二选一」——这就是朴素切块的死结。

真正的破局思路:放弃「检索 chunk = 生成 chunk」这个隐含假设。


二、核心思想:把「检索」和「生成」解耦

整个索引优化章节的思想,可以浓缩成一句话:

把「检索单元」和「生成单元」解耦——让检索用小/精/多,让生成用大/全/稳。

这套思想跟 PreRetrieval 里的 HyDE 同源——都打破了「检索单元 = 生成单元」的隐含假设:

技术检索用什么生成用什么
HyDELLM 生成的假设文档(虚拟大)真实文档(实际)
Sentence Window单句 embedding(实际小)窗口文本(实际大)
Parent-Child子块 embedding(小)父块文本(大)
Prev-Next 扩展单 chunk embeddingchunk + 相邻 N 个 chunk
MultiVector摘要 embedding(短)原文(长)

理解了「解耦」这两个字,就理解了下面所有技术的本质。


三、三大路线全景图

围绕「解耦」思想,业界演化出了三条正交路线

┌─────────────────────────────────────────────────────────────┐
│                  RAG 索引优化 · 三大路线                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   路线 A:垂直扩充        路线 B:垂直分层       路线 C:水平多视角
│   (小块→大上下文)         (先粗→后细)           (多视角投影)
│   ──────────────         ────────────         ──────────
│                                                             │
│   • Sentence Window       • 双层 Milvus         • 混合检索
│   • Parent-Child          • PandasNode          • 多表示索引
│   • Prev-Next 扩展        • 粗中有细            (摘要 / BM25)
│                            • AutoMerging          │
│                                                             │
│   召回后上下文不全         数据天然有层次        单一表示召回不准
└─────────────────────────────────────────────────────────────┘

3.1 三条路线的本质区别

路线解决的痛点「大尺寸」的来源
A 垂直扩充召回的小块上下文不全结构关系(窗口/父块/邻居)从同一文档扩出来
B 垂直分层数据天然有层次,先定位层再精取指针/树从粗层跳到细层
C 水平多视角单一表示召回不准多视角表示(摘要/BM25/多查询)做并联

关键洞察:三条路线互相正交,可栈式叠加——工业级 RAG 通常是它们的组合,下文会专门展开。

接下来我们逐一深入每条路线。


四、路线 A:垂直扩充 ——「小块检索,大块生成」

核心思想

检索用小块(精准定位),生成用大块(完整上下文)。
"大块"通过结构关系(窗口/父子/邻居)从同一个文档里扩出来。

三种实现路径:

  1. Sentence Window:索引时存双尺寸,查询时切换
  2. Parent-Child:两层存储,子块检索后映射到父块
  3. Prev-Next 扩展:检索后按需拼接相邻 Node

记忆口诀:窗口贴在身上、父子靠外键、邻居看队伍

4.1 Sentence Window(句子滑动窗口)

4.1.1 设计原理

把文档按自然句切成 Node(一句话一个 Node),每个 Node 同时携带两种内容:

  • 小尺寸(当前句):走向量库做 embedding
  • 大尺寸(前后 N 句窗口):静静待在 metadata 里待命

为什么这样做?

  • 单句的 embedding 语义最聚焦——检索精度最高
  • 但单句信息不全(指代词、上下文缺失)——所以同时存窗口
  • 检索时按"小尺寸"匹配定位,喂 LLM 时按"大尺寸"输出
4.1.2 关键实现(LlamaIndex)

写入端:SentenceWindowNodeParser

from llama_index.core.node_parser import SentenceWindowNodeParser

node_parser = SentenceWindowNodeParser.from_defaults(
    window_size=3,                       # 窗口半径(前后各取 3 句)
    window_metadata_key="window",        # 大尺寸存放字段
    original_text_metadata_key="original_text"  # 当前句子备份字段
)

对每条句子产出一个 Node:

原文(按句子编号):
  S1   S2   S3   [S4]   S5   S6   S7   S8 ...

对 S4 生成的 Node:
  node.text                      = "S4"                    ← 走 embedding 的内容(小)
  node.metadata["original_text"] = "S4"                    ← 当前句子备份
  node.metadata["window"]        = "S1 S2 S3 S4 S5 S6 S7"  ← 上下文窗口(大)

读取端:MetadataReplacementPostProcessor

from llama_index.core.postprocessor import MetadataReplacementPostProcessor

postprocessor = MetadataReplacementPostProcessor(target_metadata_key="window")
4.1.3 实战流程图
建索引阶段(一次性,离线):
┌─────────────────────────────────────────┐
│  原文                                     │
│    ↓                                      │
│  SentenceWindowNodeParser                 │
│    ↓                                      │
│  N 个 Node,每个 = 单句(body) + 窗口(meta) │
│    ↓                                      │
│  VectorStoreIndex(embedding 的是单句)   │
└─────────────────────────────────────────┘

查询阶段(每次 query 都跑):
┌─────────────────────────────────────────┐
│  用户 query                              │
│    ↓                                     │
│  向量检索(按单句 embedding)            │
│    ↓                                     │
│  top-k 个 Node(body 仍是单句)          │
│    ↓                                     │
│  MetadataReplacementPostProcessor        │
│    ↓ 把 body 替换成 window metadata      │
│  top-k 个 Node(body 变成窗口)          │
│    ↓                                     │
│  喂给 LLM 生成答案                        │
└─────────────────────────────────────────┘

重要细节:它不修改向量库里存的内容,只改本次查询内存里的副本——多次查询不会污染数据。

4.1.4 实测对比:同一问题的效果差异

问「游戏中悟空有哪些形态变化?」:

引擎答案
窗口引擎(top-k=2 + window)同时答出金刚 + 魔佛 两种形态 ✓
基础引擎(top-k=6,50 字符切)只答出魔佛形态(chunk 切散了描述) ✗

结论:窗口索引「召回更精准(单句聚焦),给的上下文反而更大」——这就是解耦的价值。

4.1.5 适用场景
✅ 适合❌ 不适合
短文本、句子边界清晰(论文摘要、新闻、说明书)强结构化文档(表格、列表)
知识密集、要求精准定位的文档超长文档(每个 Node 一个窗口,开销线性增长)
对延迟敏感(查询时只做内存替换,不调 LLM)句子边界模糊的散乱笔记

4.2 Parent-Child(父子文本块检索)

4.2.1 设计原理

跟 Sentence Window 思路一致——「小块检索、大块生成」,但实现路径完全不同。它用两层存储系统

  • 父块(Parent):原文切成大块(如 1000 字符一段),代表完整语义单元
  • 子块(Child):每个父块再切成小块(如 200 字符一段),代表精准检索单元
  • 父子关系:每个子块身上挂着一个"我爸爸是谁"的标记(parent_doc_id
4.2.2 关键实现(LangChain)
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_chroma import Chroma
from langchain_classic.storage import InMemoryStore
from langchain_classic.retrievers import ParentDocumentRetriever

# 两层切分
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
child_splitter  = RecursiveCharacterTextSplitter(chunk_size=200,  chunk_overlap=50)

# 两个存储
vectorstore = Chroma(...)   # 子块入向量库
store = InMemoryStore()     # 父块入键值存储

# 父文档检索器:自动维护子块 → 父块 ID 的映射
retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=store,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)
retriever.add_documents(documents)
4.2.3 存储结构与查询流程
┌────────────────────────────────┐
│  vectorstore (Chroma)          │
│  ─────────────────             │
│  child_id │ 子块文本 │ 向量     │
│  child_id │ 子块文本 │ 向量     │
│  每个 child 都带 parent_id 标记  │
└───────────────┬────────────────┘
                │ 拿 parent_id 跳转
                ▼
┌────────────────────────────────┐
│  docstore (InMemoryStore)      │
│  ─────────────────             │
│  parent_id → 完整父块 (1000字)  │
└────────────────────────────────┘

查询流程

query → 向量库检索子块(小,精准)
     → 拿子块的 parent_doc_id
     → 去 docstore 拿对应的父块(大,完整)
     → 父块喂给 LLM
4.2.4 Sentence Window vs Parent-Child 横向对比
维度Sentence WindowParent-Child
「大尺寸」来源单句的前后 N 句邻域整个父块
「大尺寸」形态metadata 里的字符串独立存储的父文档
父子关系隐式(位置邻接)显式(parent_doc_id
存储单库(每个 Node 自带窗口)双库(vectorstore + docstore)
窗口大小固定(参数化)可独立设定(父 splitter)
典型框架LlamaIndexLangChain

生活类比

  • Sentence Window = 便利贴正面写单句、背面贴前后 3 句复印件
  • Parent-Child = 索引卡上写"这一段在第三章",找到卡再去翻第三章

4.3 Prev-Next 扩展(前后向扩展上下文)

4.3.1 跟前两者的根本差别

不预先切双尺寸,而是检索时按需"借邻居"

把所有 Node 按文档顺序排成一条长队伍,每个 Node 都记得自己排在第几号。检索到某个 Node 后,根据相邻 ID 关系把前 N 个或后 N 个邻居"借"过来一起塞进 context。

4.3.2 两种扩展模式

模式 1:固定扩展 — PrevNextNodePostprocessor

from llama_index.core.postprocessor import PrevNextNodePostprocessor

postprocessor = PrevNextNodePostprocessor(docstore=docstore, num_nodes=2)

无条件扩展:无论问题是什么,都把召回 Node 的前 2 个 + 后 2 个邻居一起拿过来。

模式 2:智能扩展 — AutoPrevNextNodePostprocessor

from llama_index.core.postprocessor import AutoPrevNextNodePostprocessor

postprocessor = AutoPrevNextNodePostprocessor(
    docstore=docstore,
    num_nodes=3,
    verbose=True
)

按需扩展:让 LLM 判断"这个问题需要前文还是后文",输出 previous / next / both / none,然后只往那个方向扩展。

举个实战例子

问 "悟空从忘川寺获得记忆后发生了什么?"  → LLM 判 next(看后文)
问 "悟空为什么会在山洞中醒来?"          → LLM 判 previous(看前文)
4.3.3 节点队伍扩展图解
节点队伍:  N1  N2  N3  N4  N5  N6  N7  N8  N9 ...
                          ↑
                     被检中的锚点

固定扩展取:    [N3  N4  N5  N6  N7]          ← 前后各 2 个
智能扩展取前:  [N2  N3  N4  N5]               ← 只往前 3 个
智能扩展取后:              [N5  N6  N7  N8]    ← 只往后 3 个
4.3.4 适用场景
✅ 适合❌ 不适合
叙事性、强时序文档(小说、剧情、流程文档、对话记录)知识点独立的文档(百科、字典)
需要看上下文脉络的问题(“接下来发生了什么”/“为什么”)对延迟极敏感 → Auto 模式要额外调一次 LLM
文档结构混乱、无法预知方向 → 用固定扩展

4.4 路线 A 三兄弟横向对比

维度Sentence WindowParent-ChildPrev-Next 扩展
「大尺寸」来源单句的前后 N 句邻域整个父块相邻 N 个 Node
「大尺寸」形态metadata 里的字符串独立存储的父文档查询时动态拼接
父子关系隐式(位置邻接)显式(parent_doc_id隐式(按 ID 顺序)
灵活性固定窗口大小父块大小可独立设定Node 数量可调
是否需 LLM 判断❌ 否❌ 否Auto 模式 ✅ 是
计算开销低(预存好了)中(查 docstore)Auto 模式高(多调 LLM)
适合场景短文档、句子清晰长文档、层级清晰叙事性、需要脉络
典型框架LlamaIndexLangChainLlamaIndex

五、路线 B:垂直分层 ——「先粗后细,按层精取」

核心思想

用「粗层」做路由定位到范围,用「细层」在范围内精取。
粗细之间通过 filter / IndexNode 指针 / 树形父子结构 连接。

四种实现路径,从「手工版」到「全自动版」逐个演化:

  1. 双层 Milvus 索引(手工 filter 版)
  2. PandasNode(Text-to-Code 版)
  3. 粗中有细(IndexNode 指针版)
  4. AutoMerging(树形合并版)

5.1 双层 Milvus 索引(手工 filter 版)

5.1.1 应用场景与设计动机

场景:多表结构化数据(Excel 多 sheet、关系数据库多表)。比如一份《世界十大富豪.xlsx》含 2021/2022/2023 三张 sheet,每张都是一张大表。

朴素 RAG 的翻车:把所有 sheet 都拼成一段长文本进向量库——向量被各种细节稀释,检索完全失真。

双层索引的设计:建立两个独立的向量集合

集合向量来源职责
summary 集合embed(sheet_name) 例如 embed(“2023”)路由定位:哪张表
details 集合embed(整张表 to_string)内容交付:整张表

两个集合用 table_name 字段做标量关联。

5.1.2 关键实现

建索引阶段

# 1. summary 集合:embed sheet_name
summary_embedding = embedding_function.encode([sheet_name])[0]
client.insert(
    collection_name=summary_collection_name,
    data=[{
        "vector": summary_embedding.tolist(),
        "table_name": sheet_name
    }]
)

# 2. details 集合:embed 整表 to_string
table_content = df.to_string(index=False)
detail_embedding = embedding_function.encode([table_content])[0]
client.insert(
    collection_name=details_collection_name,
    data=[{
        "vector": detail_embedding.tolist(),
        "table_name": sheet_name,
        "content": table_content
    }]
)

查询阶段

# 第一层:在 summary 集合找最相关的表
summary_results = client.search(
    collection_name=summary_collection_name,
    data=[query_embedding.tolist()],
    limit=1,
    output_fields=["table_name"]
)
matched_table = summary_results[0][0]['entity']['table_name']

# 第二层:在 details 集合用 filter 锁定那张表,取整表内容
details_results = client.search(
    collection_name=details_collection_name,
    data=[query_embedding.tolist()],
    filter=f"table_name == '{matched_table}'",   # ← 标量过滤
    limit=1,
    output_fields=["content"]
)
5.1.3 全景流程图
建立索引:
┌─────────────────────────────────────────────────────┐
│        世界十大富豪.xlsx                             │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐              │
│  │ sheet:  │  │ sheet:  │  │ sheet:  │              │
│  │  2021   │  │  2022   │  │  2023   │   ...        │
│  └─────────┘  └─────────┘  └─────────┘              │
└─────────────────────────────────────────────────────┘
         │              │              │
         └──────────────┴──────────────┘
                        │
        ┌───────────────┴───────────────┐
        ▼                                ▼
【summary 向量】                   【details 向量】
  embed("2023")                    embed(整表 to_string)
        │                                │
        ▼                                ▼
   billionaires_summary             billionaires_details

查询:
   用户问题: "2023年世界首富是谁?"
                  │
                  ▼ embed
   ╔══════════════════════════════╗
   ║ 第一层:summary 检索          ║
   ║ query_v ──COSINE──> v_23     ║
   ║ matched_table = "2023"       ║
   ╚══════════════════════════════╝
                  │
                  ▼ filter="table_name == '2023'"
   ╔══════════════════════════════╗
   ║ 第二层:details 检索          ║
   ║ filter 锁定 2023 那张表       ║
   ║ 取出 content 字段(整张表)   ║
   ╚══════════════════════════════╝
                  │
                  ▼ 拼进 prompt
            LLM 生成答案
5.1.4 踩坑实录:「不成熟版」 vs 「成功版」

我学习时跑过两个版本,效果天差地别:

版本第二层 embed 什么结果
不成熟版(行级)每行单独 embed检索时只能定位到某一行,丢失整表语义 ❌
成功版(整表)整表 to_string 后 embed检索精准定位到表 ✓

教训:第二层一定要 embed 整表的 to_string,而不是行级。结构化数据的语义在「整体」不在「局部」

5.2 PandasNode(Text-to-Code 版)

5.2.1 设计动机:解决 LLM「读错表」问题

双层 Milvus 解决了「找到哪张表」,但还有一个问题没解决——LLM 看着整张表读,可能数错/读错列。比如问「前十名里多少美国人」,LLM 经常漏数或多数。

PandasNode 的解法:走完全不同的思路——不存「内容向量」,而是让 LLM 临时写 pandas 代码去查 DataFrame

5.2.2 关键实现

只建一个 LlamaIndex VectorStoreIndex,但里面塞了两类节点

  • Document:表的 to_string 全文,用于兜底/语义召回
  • IndexNode:摘要节点,不返回内容,而是「指针」,指向 PandasQueryEngine
from llama_index.experimental.query_engine import PandasQueryEngine
from llama_index.core.schema import IndexNode
from llama_index.core.retrievers import RecursiveRetriever

# 每张表创建一个 PandasQueryEngine
df_query_engines = [
    PandasQueryEngine(table_df, llm=llm_for_table) for table_df in table_dfs
]

# 为每张表创建 IndexNode 指针
df_nodes = [
    IndexNode(text=table_summaries[idx], index_id=f"pandas{idx}")
    for idx in range(len(table_summaries))
]

# 映射 IndexNode id → PandasQueryEngine
df_id_query_engine_mapping = {
    f"pandas{idx}": df_query_engine
    for idx, df_query_engine in enumerate(df_query_engines)
}

# RecursiveRetriever 路由
recursive_retriever = RecursiveRetriever(
    "vector",
    retriever_dict={"vector": vector_retriever},
    query_engine_dict=df_id_query_engine_mapping,
    verbose=True,
)
5.2.3 检索流程详解
问题: "2020年世界首富是谁?他的财富是多少?"
   │
   ▼
1) 向量检索 VectorStoreIndex(含 Document + IndexNode)
   → similarity_top_k=1 选出最相关的 IndexNode:pandas3
   │
   ▼
2) RecursiveRetriever 看到 IndexNode,查 query_engine_dict
   → 路由到 PandasQueryEngine(2020那张表)
   │
   ▼
3) PandasQueryEngine 把 DataFrame 的 schema(列名+几行示例)喂给 LLM
   → LLM 输出 pandas 代码,比如:
      df.loc[df['Net_Worth'].idxmax(), ['Name', 'Net_Worth']]
   │
   ▼
4) Python 解释器实际执行这段代码 → 拿到精确结果 "Jeff Bezos / $113B"
   │
   ▼
5) RetrieverQueryEngine 把代码执行结果交给 response_synthesizer 组织成自然语言
5.2.4 效果对比:整表入 prompt vs PandasNode
维度整表入 prompt(5.1 Milvus)PandasNode(5.2)
第二层取什么整张大表字符串DataFrame schema + LLM 生成的 pandas 代码
答案怎么算LLM 看着整张表LLM 写代码,Python 真去
数值精确度LLM 可能数错/读错列代码执行精确(filter/groupby/sum 都准)
适合的问题「首富是谁」这种概览型「前十名里多少 Americans」、「平均年龄」等聚合计算
Token 消耗整表入 prompt(贵)只入 schema(便宜,但多一次 LLM 调用)
失败模式答错(LLM 读错)代码报错(LLM 写错)

实战经验:财务报表、统计查询这种数值精度要求高的场景,PandasNode 是首选。

5.3 粗中有细(IndexNode 指针版)

5.3.1 这是 RecursiveRetriever 最教科书的用法

RecursiveRetriever + IndexNode 的「摘要→详情」双层检索——跟 5.2 用的是同一套机制,区别在第二层引擎:5.2 用 PandasQueryEngine,5.3 用普通 as_query_engine()

5.3.2 双层结构图解
顶层 VectorStoreIndex(混合两类节点)
├── doc_nodes("粗":场景概述)
│     ├── "花果山:齐天大圣的出生地..."
│     ├── "水帘洞:位于花果山之巅..."
│     └── "东海龙宫:位于东海海底..."
│
└── index_nodes("指针":摘要节点,不直接给答案)
      ├── IndexNode(text="该节点包含花果山详细设定", index_id="detail0")
      ├── IndexNode(text="该节点包含水帘洞详细设定", index_id="detail1")
      └── IndexNode(text="该节点包含东海龙宫详细设定", index_id="detail2")
              │
              ▼  通过 index_id_query_engine_mapping 路由

第二层:每个 detail 都有独立的子索引 + 子查询引擎
├── detail0 → VectorStoreIndex(花果山详细设定).as_query_engine()
├── detail1 → VectorStoreIndex(水帘洞详细设定).as_query_engine()
└── detail2 → VectorStoreIndex(东海龙宫详细设定).as_query_engine()
5.3.3 为什么「故意做粗」是必要的?

这是「粗中有细」这个名字的精髓——外表粗糙但内有精细

反例:把花果山 300 字详情直接塞进索引 →

  • 向量是这 300 字的语义平均
  • 被「练功场/仙果园/特殊区域」这些细节稀释
  • 「出生地/仙气」这些主要语义被冲淡
  • 反而可能输给一段无关的短文本

正解

  • 顶层只放短摘要(“该节点包含花果山详细设定”),向量干净、定位精准
  • "细"按需调用,不污染顶层语义

核心洞察索引的向量 ≠ 答案的来源。索引的向量只要能精准定位即可,内容可以由指针跳转拿到。

5.3.4 三个特殊设计点
  1. 同一个 VectorStoreIndex 里塞两类完全不同的节点
节点类型文本性质检索到时怎么办
Document(概述)真内容,可直接读直接喂给 LLM
IndexNode(指针)假内容(“该节点包含花果山详细设定”)不喂给 LLM,转交子引擎
  1. 第二层是 N 个独立的 VectorStoreIndex,不是一个——3 个场景就有 3 个完全独立的子索引,互不干扰。
  2. 「粗」和「细」靠 index_id 字符串映射,不靠向量相似度——顶层 IndexNode 文本和底层详情全文完全不相似(一个 13 字,一个 200 字)。
5.3.5 检索时的「两道语义闸门」串联
问题 → [顶层向检] → 筛掉无关场景 → [第二层向检] → 在选中场景里筛最相关段落 → LLM

第一道是"问题属于哪个场景",第二道是"问题想问这个场景的哪部分"。

5.4 AutoMerging(树形合并版)

5.4.1 设计动机

HierarchicalNodeParser + AutoMergingRetriever——LlamaIndex 的「多级分块 + 自动合并」方案,解决「小块检索精准但上下文碎片化」的矛盾。

跟 5.3 的核心区别:

  • 5.3 用 IndexNode 指针做条件路由(粗细靠人工设计)
  • 5.4 用树形父子结构 + 命中率阈值自动合并(粗细靠算法决定)
5.4.2 三步原理

第一步:多级嵌套分块

from llama_index.core.node_parser import HierarchicalNodeParser

node_parser = HierarchicalNodeParser.from_defaults(
    chunk_sizes=[256, 128, 64]   # 三级嵌套
)
nodes = node_parser.get_nodes_from_documents(documents)

把同一篇文档切成三层嵌套的节点树(嵌套包含关系,不是独立切片):

原文档
   │ 先按 256-token 切
┌──────────────┬──────────────┐
│ Root Chunk A │ Root Chunk B │       ← 第0层(粗)
└──────┬───────┴──────────────┘
       │ 每个 Root 再按 128-token 切
   ┌───┴───┬────┬────┐
   │Mid A1 │Mid A2│Mid A3│           ← 第1层(中)
   └───┬───┴────┴────┘
       │ 每个 Mid 再按 64-token 切
   ┌───┴──┬────┬────┐
   │Leaf  │Leaf │Leaf │               ← 第2层(细/叶子)
   └──────┴────┴────┘

关键性质:三层是嵌套包含关系——Leaf A1a + Leaf A1b 拼起来 ≈ Mid A1Mid A1 + Mid A2 拼起来 ≈ Root A。这是"无损合并"的数学前提。

第二步:只把叶子节点建索引

leaf_nodes = get_leaf_nodes(nodes)

docstore = SimpleDocumentStore()
docstore.add_documents(nodes)   # 全树入 docstore
storage_context = StorageContext.from_defaults(docstore=docstore)

base_index = VectorStoreIndex(leaf_nodes, storage_context=storage_context)
base_retriever = base_index.as_retriever(similarity_top_k=6)
  • 大块向量被平均化召回差;小块语义集中定位精准
  • 所有非叶子节点(256/128)存在 docstore 里,不进向量索引,只作"备用上下文"

第三步:自动合并

from llama_index.core.retrievers import AutoMergingRetriever

auto_merging_retriever = AutoMergingRetriever(
    base_retriever,
    storage_context,
    verbose=True
)

检索流程:

1) base_retriever 在叶子层向检,拿到 top-6 个 64-token 小块
   假设返回:[Leaf A1, Leaf A2, Leaf A3, Leaf B1, Leaf C1, Leaf C2]
   │
   ▼
2) AutoMergingRetriever 检查每个父节点的"子女命中率"
   - Mid A 的 3 个叶子(A1/A2/A3)都被命中 → 超过 50% 阈值
     ✦ 把这 3 个叶子合并成 Mid A(128-token 一整段)
   - Mid B 只有 1 个叶子(B1)被命中 → 不足阈值,保留叶子原样
   - 如果 Root A 的多个 Mid 子女都被命中 → 进一步合并成 Root A(256-token)
   │
   ▼
3) 最终返回:[Mid A (合并), Leaf B1, Mid C (合并), ...]

合并规则(默认):某个父节点的子节点超过一半被召回时,丢弃这些子节点,用父节点替换。阈值可通过 simple_ratio_thresh 调整。

5.4.3 实测:525 字符文档 → 3-6-15 三层
📊 HierarchicalNodeParser 分层结构分析
原始文档总长度: 525 字符
chunk_sizes=[256, 128, 64]
总节点数: 24
  根节点 (chunk_size=256): 3 个
  中节点 (chunk_size=128): 6 个
  叶子节点 (chunk_size=64): 15 个

每个节点实际字符数(chunk_size 是 token 不是字符,中文字符 token 密度高):

层级chunk_size (token)实际字符范围
256156–190
12864–106
6420–49
5.4.4 自动合并的「为什么」

如果不合并,6 个独立 64-token 小块喂给 LLM 的问题:

  • 碎片化:相邻信息被切断,LLM 看不到完整论述
  • 重叠浪费:6 个块可能来自同一根节点的相邻位置,token 重复
  • 缺少上下文:指代词(“该形态”/“它”)找不到所指

合并后:同一父节点的多个叶子 → 一段连贯的中块/大块;跨父节点的零散叶子 → 保留原样(确实来自不同段落)。

5.5 RAPTOR 不是 AutoMerging(重要!)

这是面试和实战中最高频的混淆点,必须澄清:

维度RAPTORAutoMerging(5.4)
出处ICLR 2024 论文LlamaIndex 内置
父节点本质子节点的LLM 摘要(抽象、压缩)子节点的原文并集(更大窗口)
构建方向自底向上:叶子 → 聚类 → 摘要 → 再聚类 → 再摘要自顶向下:用不同 chunk_size 切同一文档
关键算法GMM 软聚类 + LLM 抽象总结滑窗/递归切片
LLM 参与构建✅ 每层都要调用 LLM 生成摘要❌ 完全不调用
跨章节合并✅ 聚类会把不相邻的段落合到一起❌ 只能合并原文相邻的片段

一句话区分RAPTOR 靠「语义抽象」提升层次,AutoMerging 靠「原文回退」恢复上下文

5.6 路线 B 四件套横向对比

维度5.1 Milvus 双集合5.2 PandasNode5.3 粗中有细5.4 AutoMerging
分层手段两个独立 collectionIndexNode 指针IndexNode 指针多级嵌套分块 + 父子树
路由器人工拼 filterRecursiveRetrieverRecursiveRetrieverAutoMergingRetriever
第二层是什么整张表文本DataFrame + pandas 代码详情子索引父节点(自动升级)
谁决定走第二层filter 写死IndexNode 命中即递归IndexNode 命中即递归子节点命中率超阈值
粗细连接table_name 标量过滤index_id 对象引用index_id 对象引用parent_node.id 树形结构
适合数据多表结构化表格 + 数值聚合多主题长文档单篇超长文档
Token 效率中(整表入 prompt)高(只入 schema)中(详情全文)高(合并去重)

核心洞察:5.1 是手工版、5.2/5.3 是指针版、5.4 是树形版——四种分层策略演化路线,越来越自动化、越来越通用。


六、路线 C:水平多视角 ——「多路并联,多表示投影」

核心思想

同一份文档用多种表示同时索引进向量库,让检索器从多个视角找到它。
区别只在「多种表示」是什么——多个检索器(混合检索)或多个文档表示(多表示索引)。

一句话区分:01 是「多个检索器并联」,02 是「一个文档的多种投影」

6.1 EnsembleRetriever(混合检索)

6.1.1 设计动机

BM25 和向量检索各有所长,混合检索 = 两者并集 + 加权打分,兼顾精确召回和语义泛化。

检索器擅长弱点
BM25(稀疏/词项)精确关键词命中、专有名词、稀有词、数字代号不懂同义词、语义匹配差
向量检索(稠密/语义)语义相似、同义转写、跨语言容易「语义跑偏」、专有名词易被稀释

具体例子

查询哪个赢原因
「金刚形态的特点是什么?」BM25 赢「金刚形态」是精确专有名词
「游戏里的变身机制」向量赢「变身」≈「形态切换」是语义匹配
「悟空的战斗方式」混合赢BM25 抓「悟空」,向量抓「战斗方式」的语义
6.1.2 关键实现(LangChain)
from langchain_classic.retrievers import EnsembleRetriever, BM25Retriever
from langchain_community.vectorstores import FAISS

# 两个独立检索器
bm25_retriever = BM25Retriever.from_documents(mechanism_docs)
bm25_retriever.k = 3

faiss_retriever = FAISS.from_documents(
    worldview_docs, embedding=embedding_function
).as_retriever(search_kwargs={"k": 3})

# 混合:并联 + 加权
ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, faiss_retriever],
    weights=[0.5, 0.5]
)

「Ensemble」(集成) 借鉴的是机器学习里的 Bagging 思想——多个弱检索器组合成强检索器,类似随机森林。但这里不是「投票」,是加权融合重排序

6.1.3 融合机制:weighted RRF

EnsembleRetriever 内部用的是 Reciprocal Rank Fusion (RRF) 的加权变体:

对每个检索器返回的文档列表:
  doc_score = weight_i × (1 / (k + rank_i))    # k=60 默认常数

跨所有检索器对同一个 doc 求和 → 最终融合分数
按融合分数重排
  • weights=[0.5, 0.5]:两个检索器贡献均等
  • 调成 [0.7, 0.3]:偏 BM25(精确匹配场景)
  • 调成 [0.3, 0.7]:偏向量(语义场景)
6.1.4 RRF 为什么有效(深入推导)

为什么不用原始分数加权?

  • BM25 是 term frequency,向量是 cosine,量纲完全不同
  • 直接加权会被某个检索器的「高分尺度」主导
  • 归一化方案多且效果不稳定

RRF 只用排名位置的好处:

  1. 量纲无关——只看排名,不看绝对分
  2. 鲁棒——异常分数不会主导
  3. 简单——无需归一化

直觉:一个文档如果在两个检索器里都排名靠前,融合分数就高;只在一边靠前,则只能拿到一半贡献。

6.2 MultiVectorRetriever(多表示索引)

6.2.1 设计动机

长文档直接入库的两难:

  • 整篇作为一个 chunk → 向量被「平均化」,语义不集中,召回差
  • 切成小 chunk → 召回精准,但 LLM 拿不到完整上下文

MultiVectorRetriever 的解法用一个「替代表示」做检索,用「原文」做生成

「替代表示」可以是任何东西,常见三种:

替代表示怎么生成适用场景
摘要LLM 把长文档浓缩成一句长文章、产品描述
子问题LLM 针对文档预生成多个可能的问题FAQ、技术文档
假设文档(HyDE)LLM 根据查询先伪造一个「假答案」再检索短查询扩展
6.2.2 关键实现(LangChain)
from langchain_classic.retrievers.multi_vector import MultiVectorRetriever
from langchain_core.stores import InMemoryByteStore

# 1. 用 LLM 给每篇文档生成摘要
chain = (
    {"doc": lambda x: x.page_content}
    | ChatPromptTemplate.from_template("Summarize the following document:\n\n{doc}")
    | llm
    | StrOutputParser()
)
summaries = chain.batch(docs, {"max_concurrency": 5})

# 2. 双存储 + 指针连接
retriever = MultiVectorRetriever(
    vectorstore=vectorstore,    # 向量库存"摘要"(用于检索)
    byte_store=store,           # 字节库存"原文"(用于返回)
    id_key="doc_id",            # 连接键
)

# 3. 入库
retriever.vectorstore.add_documents(summary_docs)              # 摘要 → 向量库
retriever.docstore.mset(list(zip(doc_ids, docs)))              # 原文 → 字节库
6.2.3 存储结构与检索流程
┌─────────────────────────────────────────────┐
│  向量库(Chroma)                            │
│  ─────────────────                          │
│  doc_id=abc │ 摘要文本 │ 摘要向量           │
│  doc_id=def │ 摘要文本 │ 摘要向量           │
└──────────────────┬──────────────────────────┘
                   │ 命中后用 doc_id 跳转
                   ▼
┌─────────────────────────────────────────────┐
│  字节库(InMemoryByteStore)                │
│  ─────────────────                          │
│  abc → 原文档全文(5000 字)                │
│  def → 原文档全文(3000 字)                │
└─────────────────────────────────────────────┘

关键点:摘要只是「索引的把柄」,真正给 LLM 的是原文。

6.2.4 源码级实现解析

MultiVectorRetriever 本质是一个检索-解析分离的代理模式

def _get_relevant_documents(self, query: str) -> List[Document]:
    # Step 1: 在 vectorstore 里搜替代表示(摘要)
    sub_docs = self.vectorstore.similarity_search(query, **self.search_kwargs)

    # Step 2: 从 metadata 里提取 doc_id
    doc_ids = [d.metadata[self.id_key] for d in sub_docs]

    # Step 3: 批量从 byte_store 取原文档
    docs = self.byte_store.mget(doc_ids)

    # Step 4: 重组为 Document 列表(保留原 metadata)
    return [d for d in docs if d is not None]

关键设计决策

  1. 组合而非继承:把 vectorstore 作为成员注入,可适配任何向量库(Chroma、Pinecone、Weaviate、FAISS、Milvus)。这是经典的依赖反转
  2. doc_id 而非「引用」:避免循环引用、便于序列化、支持跨进程。
  3. BaseStore 抽象InMemoryByteStore / LocalFileStore / RedisStore / UpstashRedisStore,切后端零代码改动。
6.2.5 生产实践:摘要必须在离线阶段生成
# 千万别在请求路径上生成摘要!
# 索引阶段(离线):
for batch in chunk(docs, 32):
    summaries = llm.batch(batch)            # 批量生成
    vectorstore.add_documents(summary_docs)
    docstore.mset(zip(doc_ids, batch))

# 查询阶段(在线):
results = retriever.invoke(query)           # 零 LLM 调用,纯向量检索 + KV 取回

关键:LLM 调用从请求路径移到索引路径,请求延迟跟普通 RAG 一致。

6.2.6 MultiVectorRetriever vs ParentDocumentRetriever 边界

经常有人问:这两个都是「双存储 + 指针」,到底用哪个?

MultiVectorRetrieverParentDocumentRetriever
替代表示LLM 生成的摘要(语义浓缩)结构化切分的子块(splitter 切出来)
LLM 成本✅ 有(建索引时)❌ 零
适用场景长文档+主题分散+离线索引已有结构化切分关系

决策原则

  • 文档长且主题分散 → MultiVector(用 LLM 摘要做「语义锚点」)
  • 文档有自然章节结构 → ParentDocument(按章节切,零成本)

6.3 路线 C 双子星横向对比

维度混合检索(Ensemble)多表示索引(MultiVector)
「多」的层面多个检索器多个文档表示
每个文档入库次数1 次(同时进两个索引)N 次(每个表示进一次)
融合时机检索后融合(RRF)检索前就分好工(向量检索→docstore)
核心解决的问题单一检索器召回不全长文档检索精度 vs 上下文完整性
典型框架组件EnsembleRetrieverMultiVectorRetriever
额外开销跑两次检索LLM 生成替代表示(一次性,可缓存)
可叠加性✅ 可与任何检索器组合✅ 可与任何底层 vectorstore 组合

七、栈式组合:工业级 RAG 检索栈

7.1 三条路线为什么必须叠加

实际生产中,没有银弹——单一路线只能解决一个维度的问题:

你的痛点是什么?
│
├─ 检索召回准确,但 LLM 答案「东一榔头西一棒子」
│  → 选 路线 A:垂直扩充
│
├─ 数据天然有层次(多表/多主题/长文档)
│  → 选 路线 B:垂直分层
│
├─ 单一表示召回不准(漏召 / 误召)
│  → 选 路线 C:水平多视角
│
└─ 同时有多个痛点 → 栈式组合

工业级 RAG 通常是它们的组合。

7.2 三段式经典栈

              工业级 RAG 检索栈
                    │
   ┌────────────────┼────────────────────┐
   ▼                ▼                    ▼
[C: 多视角]    [B: 分层]              [A: 扩充]
MultiQuery     RecursiveRetriever    Parent-Child
   +              +                     +
Ensemble       AutoMerging           Prev-Next
(BM25+向量)

7.3 实战推荐组合

配方适用场景
C+B MultiVector 摘要索引 + RecursiveRetriever 原文分层长文档+多主题
C+A Ensemble(BM25+向量)底层 + AutoMergingRetriever通用增强
A+B Parent-Child 块级扩充 + RecursiveRetriever 层级路由多章节技术文档
C+C+B+A MultiQuery + Ensemble + AutoMerging + Prev-Next极致召回率(成本高)

7.4 跟其他 Retriever 的协同

配对协同方式
MultiQueryRetriever + MultiVector一个改查询(多查询),一个改文档(多表示)
SelfQueryRetriever + 任何上述加结构化 filter,正交可叠加
ContextualCompressionRetriever + 任何上述召回后做二次精筛
HyDE + Ensemble假设文档检索 + BM25+向量融合

7.5 真实案例:PDF 财报问答系统的栈式设计

场景:财报同时含叙事文本 + 多张表格 + 跨年对比,单一切块必然失败。

推荐栈(A+B+C 组合):

1. PDF 解析层:
   - 叙事文本 → PyMuPDFReader / Unstructured
   - 表格 → camelot / Unstructured / LlamaParse
   - 按页码和章节切分文档类型

2. 索引层(路线 B):
   - 叙事文本 → AutoMerging(树形合并,处理长章节)
   - 表格 → PandasNode + RecursiveRetriever(聚合计算)
   - 摘要层 → 顶层 IndexNode 做主题路由

3. 检索层(路线 C):
   - EnsembleRetriever(BM25 + 向量)
   - 处理"专有名词 + 语义泛化"混合查询

4. 上下文层(路线 A):
   - Prev-Next 扩展(前后章节衔接)
   - 必要时叠加 Sentence Window

5. 精排层:
   - 重排器(BAAI/bge-reranker-large)
   - 最后精筛 top-5

生产考虑

  • 离线索引(财报一年四次更新,更新频率低)
  • 增量更新策略(只对变化的章节重建)
  • 评测驱动选型(先基线,再按瓶颈叠加)

八、踩坑实录:8 大反模式警告

我把学习过程中遇到的、网上常见的错误用法整理成「反模式」清单,希望读者少走弯路:

8.1 反模式清单

反模式为什么错正确做法
Sentence Window 用 window_size=20metadata 冗余、跨段污染默认 3,长文档最多 5
ParentDocumentRetriever 把父块切 5000 字符父块本身就被稀释了父块 1000–2000 字符,子块 200 字符
AutoMerging 用在 500 字短文档三层退化为「1根=1中=1叶」短文档直接朴素 RAG
PandasNode 用在不规则表LLM 写不出正确 pandas 代码不规则表先用 Unstructured 整理
MultiVectorRetriever 在请求路径生成摘要延迟爆炸离线索引 + 缓存
EnsembleRetriever 用纯语义场景多跑一次 BM25 没收益纯语义直接用向量
MetadataReplacementPostProcessor 当索引修改工具它只改内存副本要改索引请重建
AutoMerging 当成 RAPTOR两者机制完全不同要 RAPTOR 用专门实现

8.2 重点踩坑实录

坑 1:以为 AutoMerging 就是 RAPTOR

学习时我把这两个搞混了好久。两者机制完全不同

  • RAPTOR:自底向上 + GMM 聚类 + LLM 摘要 → 跨章节抽象合并
  • AutoMerging:自顶向下 + 多级切片 + 命中率合并 → 同章节原文回退

记一句:RAPTOR 抽象,AutoMerging 还原

坑 2:PandasNode 的 import 坑

PandasQueryEnginellama-index-experimental 包,但 experimental 顶层 __init__ 强制 import mistralai,可能撞包结构问题。解决方法:注入空壳顶层模块屏蔽副作用。

坑 3:LangChain 1.0 import 迁移

升级到 LangChain 1.0 后,一堆 import 路径变了,跑老代码会报错:

旧路径(langchain 0.x)新路径(langchain 1.0)
langchain.chains.RetrievalQAlangchain_classic.chains.RetrievalQA
langchain.retrievers.EnsembleRetrieverlangchain_classic.retrievers.EnsembleRetriever
langchain.retrievers.multi_vector.MultiVectorRetrieverlangchain_classic.retrievers.multi_vector.MultiVectorRetriever
langchain.storage.InMemoryByteStorelangchain_core.stores.InMemoryByteStore
坑 4:Milvus Lite 每次启动需要 load_collection

Milvus Lite 是文件级存储,但每次重启进程后,collection 不会自动加载到内存,需要显式调用:

for _coll in ("billionaires_summary", "billionaires_details"):
    client.load_collection(_coll)

否则查询会报「collection not loaded」错误。

坑 5:小数据集不要用 IVF_FLAT

数据量极小(每集合 ≤ 5 条)时,IVF_FLAT 的聚类会报警告(n_clusters > n_samples)。改用 FLAT 暴力搜索即可——反正数据量小,性能差异可忽略。


九、选型决策树与评测

9.1 一图流的选型决策树

你的痛点是什么?
│
├─ 检索召回准确,但 LLM 答案"东一榔头西一棒子"
│  → 选 路线 A:垂直扩充
│     │
│     ├─ 文本短、句子边界清晰          → Sentence Window
│     ├─ 文本长、有自然章节结构         → Parent-Child
│     └─ 叙事性、强时序                → Prev-Next 扩展(Auto)
│
├─ 数据天然有层次(多表/多主题/长文档)
│  → 选 路线 B:垂直分层
│     │
│     ├─ 多表结构化数据                → Milvus 双集合 (5.1)
│     ├─ 需要聚合计算(求和/平均/排名) → PandasNode (5.2)
│     ├─ 多主题长文档                  → 粗中有细 (5.3)
│     └─ 单篇超长文档                  → 分层合并 (5.4)
│
├─ 单一表示召回不准(漏召 / 误召)
│  → 选 路线 C:水平多视角
│     │
│     ├─ 关键词精确 + 语义泛化都要      → 混合检索 Ensemble (6.1)
│     └─ 长文档向量被稀释              → 多表示 MultiVector(摘要+原文)(6.2)
│
└─ 同时有多个痛点
   → 栈式组合(见第七章)

9.2 详细场景对照表

你的场景推荐方案原因
论文摘要、新闻、说明书Sentence Window实现最简单,效果显著
书、长技术手册、有章节Parent-Child父块按章节切,上下文完整
小说、剧情、流程文档Prev-Next 扩展(Auto)LLM 智能判断方向
多 sheet Excel、关系数据库Milvus 双集合天然多表结构
财务报表、统计查询PandasNode数值聚合精确
多场景设定集、多模块文档粗中有细顶层摘要 + 详情子引擎
单篇 5000+ 字超长文AutoMerging树形合并自动恢复上下文
含专有名词 + 语义泛化EnsembleRetrieverBM25 + 向量并联
长博客、产品手册、论文库MultiVectorRetriever摘要索引 + 原文返回
对延迟敏感Sentence Window查询时只做内存替换,不调 LLM
对召回率极致追求组合使用多重保险

9.3 评测指标

检索层指标

  • Recall@K:top-K 召回中是否包含 gold chunk(越高越好)
  • MRR (Mean Reciprocal Rank):gold chunk 的平均倒数排名
  • NDCG:考虑排名位置的归一化增益
  • Hit Rate:是否命中(二元指标)

生成层指标

  • Faithfulness:答案是否忠于检索内容(防幻觉)
  • Answer Relevance:答案是否切题
  • Context Precision:召回的 context 中相关部分比例
  • Context Recall:相关 context 是否都被召回

常用工具

  • Ragas(开源 RAG 评估框架)
  • TruLens(可视化追踪)
  • LlamaIndex 的 evaluation 模块

9.4 评测驱动选型的实战建议

先单点突破,再按瓶颈叠加——不要一上来就全家桶:

1. 基线版(朴素 RAG)→ 测评 → 找瓶颈
   │
   ├─ 召回率不够?
   │  → 先看 chunk_size(最便宜的优化)
   │  → 还不够?上 EnsembleRetriever(路线 C)
   │
   ├─ 召回率够,但答案断章取义?
   │  → 上 Sentence Window 或 Parent-Child(路线 A)
   │
   ├─ 数据有自然层次(多表/多主题)?
   │  → 上路线 B(按数据类型选 5.1/5.2/5.3/5.4)
   │
   └─ 都试过还不够?
      → 上栈式组合(先 C+B,再叠 A)
      → 同时引入重排器(bge-reranker)

反模式

  • ❌ 没测基线就堆技术 → 不知道哪个有用
  • ❌ 一次性引入三个组件 → 出问题无法定位
  • ❌ 没考虑延迟 → 每多一层检索,P99 翻倍

经验法则:每加一个组件,必须能用评测指标证明它带来了可量化的提升——否则就是过度工程。


十、写在最后

10.1 一句话总结全文

索引优化的全部秘密就一句话:让「检索单元」和「生成单元」解耦。

三条路线分别是这句口号的不同实现:

  • A 垂直扩充:小块检索 → 用结构关系扩到大上下文
  • B 垂直分层:粗层定位 → 用指针/树精取细层
  • C 水平多视角:多视角表示 → 用并联/投影提高召回

工程上,这三条路线正交可叠加——理解了它们,就理解了 90% 的 RAG 索引优化技术。

10.2 记忆口诀

  • 路线 A 三兄弟:窗口贴身、父子外键、邻居排队
  • 路线 B 四件套:手工 filter、代码算、指针跳、树形合
  • 路线 C 双子星:多检索器并联、多表示投影

10.3 易混概念速查表

概念对易混点区分方法
AutoMerging vs RAPTOR都是「多层索引」AutoMerging 原文合并、RAPTOR 摘要抽象
Parent-Child vs MultiVector都用双存储P-C 是 splitter 切分(零 LLM),MV 是 LLM 摘要
Ensemble vs MultiVector都是「多」Ensemble 多检索器并联,MV 多表示投影
Sentence Window vs Parent-Child都「小块检索大块生成」SW 是位置窗口(隐式),P-C 是显式外键
RecursiveRetriever vs AutoMergingRetriever都是「递归」RR 靠 IndexNode 指针条件递归,AM 靠树形命中率自动合并
Prev-Next vs Sentence Window都扩上下文Prev-Next 是 Node 级(chunk 间),SW 是句子级(句间)

10.4 推荐学习路径

如果你刚开始学 RAG 索引优化,建议按这个顺序:

  1. 第一周:吃透朴素 RAG 的痛点,理解 chunk_size 两难
  2. 第二周:上手 Sentence Window(最简单),跑通基线对比
  3. 第三周:学 Parent-Child 和 Prev-Next,理解「解耦」思想
  4. 第四周:上路线 B 的 RecursiveRetriever + IndexNode(粗中有细)
  5. 第五周:学 AutoMerging,对比 RAPTOR 区分清楚
  6. 第六周:上路线 C 的 EnsembleRetriever 和 MultiVectorRetriever
  7. 第七周:栈式组合 + 评测驱动选型
  8. 第八周:实战一个完整项目(财报问答 / 产品手册问答)

10.5 最后的最后

写这篇文章的过程,是我自己重新梳理 RAG 索引优化知识体系的过程。最大的收获是:

不要被五花八门的技术名词吓到——所有索引优化技术,本质都在回答同一个问题:如何让「检索」和「生成」各司其职。

理解了这个第一性原理,再看任何新技术(RAPTOR、GraphRAG、ColBERT…),你都能迅速定位它在「解耦」这盘棋上扮演什么角色。


如果这篇文章对你有帮助,欢迎点赞+收藏+关注。后续我会继续更新 RAG 系列的实战内容,包括:

  • PreRetrieval(查询路由、HyDE、MultiQuery)
  • Retrieval(向量检索、重排、压缩)
  • PostRetrieval(生成、引用、幻觉控制)
  • 评测与可观测性

有任何问题或想法,欢迎评论区交流。下篇文章见!


附·本文涉及代码与笔记:所有示例代码均来自开源项目 rag-in-action,可对照运行。文章中的 ASCII 流程图、对比表均整理自项目实战经验。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值