万字长文吃透 RAG 索引优化:从「朴素切块翻车」到「工业级检索栈」的三大路线实战
写在前面:本文是作者在学习 RAG(Retrieval-Augmented Generation)过程中踩过无数坑之后,针对索引优化(Indexing)这一环做的系统性总结。区别于网上零散的概念科普,本文从「朴素 RAG 为什么翻车」这个真实痛点切入,按「问题溯源 → 思想破局 → 三大路线 → 工程栈式组合 → 踩坑实录」的脉络展开,配合可运行的代码片段、ASCII 流程图、对比实测数据,力求做到「读完就能用」。
全文约 1.5 万字,建议收藏后慢慢看。如果有收获,文末点赞+关注就是对我最大的鼓励。
目录
- 一、问题溯源:朴素 RAG 到底在哪里翻车
- 二、核心思想:把「检索」和「生成」解耦
- 三、三大路线全景图
- 四、路线 A:垂直扩充 ——「小块检索,大块生成」
- 五、路线 B:垂直分层 ——「先粗后细,按层精取」
- 六、路线 C:水平多视角 ——「多路并联,多表示投影」
- 七、栈式组合:工业级 RAG 检索栈
- 八、踩坑实录:8 大反模式警告
- 九、选型决策树与评测
- 十、写在最后
一、问题溯源:朴素 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 同源——都打破了「检索单元 = 生成单元」的隐含假设:
| 技术 | 检索用什么 | 生成用什么 |
|---|---|---|
| HyDE | LLM 生成的假设文档(虚拟大) | 真实文档(实际) |
| Sentence Window | 单句 embedding(实际小) | 窗口文本(实际大) |
| Parent-Child | 子块 embedding(小) | 父块文本(大) |
| Prev-Next 扩展 | 单 chunk embedding | chunk + 相邻 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:垂直扩充 ——「小块检索,大块生成」
核心思想
检索用小块(精准定位),生成用大块(完整上下文)。
"大块"通过结构关系(窗口/父子/邻居)从同一个文档里扩出来。
三种实现路径:
- Sentence Window:索引时存双尺寸,查询时切换
- Parent-Child:两层存储,子块检索后映射到父块
- 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 Window | Parent-Child |
|---|---|---|
| 「大尺寸」来源 | 单句的前后 N 句邻域 | 整个父块 |
| 「大尺寸」形态 | metadata 里的字符串 | 独立存储的父文档 |
| 父子关系 | 隐式(位置邻接) | 显式(parent_doc_id) |
| 存储 | 单库(每个 Node 自带窗口) | 双库(vectorstore + docstore) |
| 窗口大小 | 固定(参数化) | 可独立设定(父 splitter) |
| 典型框架 | LlamaIndex | LangChain |
生活类比:
- 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 Window | Parent-Child | Prev-Next 扩展 |
|---|---|---|---|
| 「大尺寸」来源 | 单句的前后 N 句邻域 | 整个父块 | 相邻 N 个 Node |
| 「大尺寸」形态 | metadata 里的字符串 | 独立存储的父文档 | 查询时动态拼接 |
| 父子关系 | 隐式(位置邻接) | 显式(parent_doc_id) | 隐式(按 ID 顺序) |
| 灵活性 | 固定窗口大小 | 父块大小可独立设定 | Node 数量可调 |
| 是否需 LLM 判断 | ❌ 否 | ❌ 否 | Auto 模式 ✅ 是 |
| 计算开销 | 低(预存好了) | 中(查 docstore) | Auto 模式高(多调 LLM) |
| 适合场景 | 短文档、句子清晰 | 长文档、层级清晰 | 叙事性、需要脉络 |
| 典型框架 | LlamaIndex | LangChain | LlamaIndex |
五、路线 B:垂直分层 ——「先粗后细,按层精取」
核心思想
用「粗层」做路由定位到范围,用「细层」在范围内精取。
粗细之间通过 filter / IndexNode 指针 / 树形父子结构 连接。
四种实现路径,从「手工版」到「全自动版」逐个演化:
- 双层 Milvus 索引(手工 filter 版)
- PandasNode(Text-to-Code 版)
- 粗中有细(IndexNode 指针版)
- 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 三个特殊设计点
- 同一个 VectorStoreIndex 里塞两类完全不同的节点
| 节点类型 | 文本性质 | 检索到时怎么办 |
|---|---|---|
Document(概述) | 真内容,可直接读 | 直接喂给 LLM |
IndexNode(指针) | 假内容(“该节点包含花果山详细设定”) | 不喂给 LLM,转交子引擎 |
- 第二层是 N 个独立的 VectorStoreIndex,不是一个——3 个场景就有 3 个完全独立的子索引,互不干扰。
- 「粗」和「细」靠
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 A1,Mid 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) | 实际字符范围 |
|---|---|---|
| 根 | 256 | 156–190 |
| 中 | 128 | 64–106 |
| 叶 | 64 | 20–49 |
5.4.4 自动合并的「为什么」
如果不合并,6 个独立 64-token 小块喂给 LLM 的问题:
- 碎片化:相邻信息被切断,LLM 看不到完整论述
- 重叠浪费:6 个块可能来自同一根节点的相邻位置,token 重复
- 缺少上下文:指代词(“该形态”/“它”)找不到所指
合并后:同一父节点的多个叶子 → 一段连贯的中块/大块;跨父节点的零散叶子 → 保留原样(确实来自不同段落)。
5.5 RAPTOR 不是 AutoMerging(重要!)
这是面试和实战中最高频的混淆点,必须澄清:
| 维度 | RAPTOR | AutoMerging(5.4) |
|---|---|---|
| 出处 | ICLR 2024 论文 | LlamaIndex 内置 |
| 父节点本质 | 子节点的LLM 摘要(抽象、压缩) | 子节点的原文并集(更大窗口) |
| 构建方向 | 自底向上:叶子 → 聚类 → 摘要 → 再聚类 → 再摘要 | 自顶向下:用不同 chunk_size 切同一文档 |
| 关键算法 | GMM 软聚类 + LLM 抽象总结 | 滑窗/递归切片 |
| LLM 参与构建 | ✅ 每层都要调用 LLM 生成摘要 | ❌ 完全不调用 |
| 跨章节合并 | ✅ 聚类会把不相邻的段落合到一起 | ❌ 只能合并原文相邻的片段 |
一句话区分:RAPTOR 靠「语义抽象」提升层次,AutoMerging 靠「原文回退」恢复上下文。
5.6 路线 B 四件套横向对比
| 维度 | 5.1 Milvus 双集合 | 5.2 PandasNode | 5.3 粗中有细 | 5.4 AutoMerging |
|---|---|---|---|---|
| 分层手段 | 两个独立 collection | IndexNode 指针 | IndexNode 指针 | 多级嵌套分块 + 父子树 |
| 路由器 | 人工拼 filter | RecursiveRetriever | RecursiveRetriever | AutoMergingRetriever |
| 第二层是什么 | 整张表文本 | 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 只用排名位置的好处:
- 量纲无关——只看排名,不看绝对分
- 鲁棒——异常分数不会主导
- 简单——无需归一化
直觉:一个文档如果在两个检索器里都排名靠前,融合分数就高;只在一边靠前,则只能拿到一半贡献。
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]
关键设计决策:
- 组合而非继承:把
vectorstore作为成员注入,可适配任何向量库(Chroma、Pinecone、Weaviate、FAISS、Milvus)。这是经典的依赖反转。 - 用
doc_id而非「引用」:避免循环引用、便于序列化、支持跨进程。 - 用
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 边界
经常有人问:这两个都是「双存储 + 指针」,到底用哪个?
| MultiVectorRetriever | ParentDocumentRetriever | |
|---|---|---|
| 替代表示 | LLM 生成的摘要(语义浓缩) | 结构化切分的子块(splitter 切出来) |
| LLM 成本 | ✅ 有(建索引时) | ❌ 零 |
| 适用场景 | 长文档+主题分散+离线索引 | 已有结构化切分关系 |
决策原则:
- 文档长且主题分散 → MultiVector(用 LLM 摘要做「语义锚点」)
- 文档有自然章节结构 → ParentDocument(按章节切,零成本)
6.3 路线 C 双子星横向对比
| 维度 | 混合检索(Ensemble) | 多表示索引(MultiVector) |
|---|---|---|
| 「多」的层面 | 多个检索器 | 多个文档表示 |
| 每个文档入库次数 | 1 次(同时进两个索引) | N 次(每个表示进一次) |
| 融合时机 | 检索后融合(RRF) | 检索前就分好工(向量检索→docstore) |
| 核心解决的问题 | 单一检索器召回不全 | 长文档检索精度 vs 上下文完整性 |
| 典型框架组件 | EnsembleRetriever | MultiVectorRetriever |
| 额外开销 | 跑两次检索 | 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=20 | metadata 冗余、跨段污染 | 默认 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 坑
PandasQueryEngine 在 llama-index-experimental 包,但 experimental 顶层 __init__ 强制 import mistralai,可能撞包结构问题。解决方法:注入空壳顶层模块屏蔽副作用。
坑 3:LangChain 1.0 import 迁移
升级到 LangChain 1.0 后,一堆 import 路径变了,跑老代码会报错:
| 旧路径(langchain 0.x) | 新路径(langchain 1.0) |
|---|---|
langchain.chains.RetrievalQA | langchain_classic.chains.RetrievalQA |
langchain.retrievers.EnsembleRetriever | langchain_classic.retrievers.EnsembleRetriever |
langchain.retrievers.multi_vector.MultiVectorRetriever | langchain_classic.retrievers.multi_vector.MultiVectorRetriever |
langchain.storage.InMemoryByteStore | langchain_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 | 树形合并自动恢复上下文 |
| 含专有名词 + 语义泛化 | EnsembleRetriever | BM25 + 向量并联 |
| 长博客、产品手册、论文库 | 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 索引优化,建议按这个顺序:
- 第一周:吃透朴素 RAG 的痛点,理解 chunk_size 两难
- 第二周:上手 Sentence Window(最简单),跑通基线对比
- 第三周:学 Parent-Child 和 Prev-Next,理解「解耦」思想
- 第四周:上路线 B 的 RecursiveRetriever + IndexNode(粗中有细)
- 第五周:学 AutoMerging,对比 RAPTOR 区分清楚
- 第六周:上路线 C 的 EnsembleRetriever 和 MultiVectorRetriever
- 第七周:栈式组合 + 评测驱动选型
- 第八周:实战一个完整项目(财报问答 / 产品手册问答)
10.5 最后的最后
写这篇文章的过程,是我自己重新梳理 RAG 索引优化知识体系的过程。最大的收获是:
不要被五花八门的技术名词吓到——所有索引优化技术,本质都在回答同一个问题:如何让「检索」和「生成」各司其职。
理解了这个第一性原理,再看任何新技术(RAPTOR、GraphRAG、ColBERT…),你都能迅速定位它在「解耦」这盘棋上扮演什么角色。
如果这篇文章对你有帮助,欢迎点赞+收藏+关注。后续我会继续更新 RAG 系列的实战内容,包括:
- PreRetrieval(查询路由、HyDE、MultiQuery)
- Retrieval(向量检索、重排、压缩)
- PostRetrieval(生成、引用、幻觉控制)
- 评测与可观测性
有任何问题或想法,欢迎评论区交流。下篇文章见!
附·本文涉及代码与笔记:所有示例代码均来自开源项目 rag-in-action,可对照运行。文章中的 ASCII 流程图、对比表均整理自项目实战经验。



被折叠的 条评论
为什么被折叠?



