010-01:RAG 入门-索引优化,从小块到大上下文:提升检索精度与上下文完整性

本文是 refine-rag 系列教程的第十四篇,我们来学习索引优化中的"从小块到大上下文"技术。
本文所有代码都在:https://github.com/zonezoen/refine-rag

往期系列文章

目录

  • • 前言

  • • 什么是"从小块到大上下文"

  • • 为什么需要这个技术

  • • 三种实现方法对比

  • • 性能对比

  • • 实战案例

  • • 参数调优指南

  • • 最佳实践

  • • 常见问题

  • • 进阶技巧

  • • 相关知识点

  • • 学习路径

前言

前面我们学习了向量嵌入与检索、向量存储等技术,知道如何将文档切块并进行检索。但在实际应用中,我们遇到了一个矛盾:检索时需要小文本块来提高精度,但理解时需要大文本块来保证上下文完整。

这就像在图书馆找资料,你用关键词快速定位到某一页,但要真正理解内容,你需要阅读整个章节。这篇文章将介绍如何解决这个矛盾,实现"用小块检索,用大块理解"。

什么是"从小块到大上下文"?

想象你在图书馆找资料:

  • • 你用关键词(小块)在目录中快速定位

  • • 但你需要阅读整个章节(大上下文)才能真正理解

这就是"从小块到大上下文"的核心思想:用小块检索,用大块理解

为什么需要这个技术?

在RAG系统中存在一个矛盾:

检索阶段:需要小文本块
├─ 优点:匹配更精确,噪声更少
└─ 缺点:信息不完整,缺乏上下文

理解阶段:需要大文本块
├─ 优点:上下文完整,信息充分
└─ 缺点:如果直接检索大块,精度会下降

解决方案:检索时用小块定位,返回时给大块内容。


三种实现方法对比

方法1:句子滑动窗口(Sentence Window)

原理:像放大镜一样,检索到一个句子后,自动展示它前后的句子。

通俗比喻

原文:[句1][句2][句3][句4][句5][句6][句7]

检索匹配到:句4

返回内容(window_size=2):
[句2][句3][句4][句5][句6]
      ↑前2句  ↑目标  ↑后2句

代码示例

from llama_index.core.node_parser import SentenceWindowNodeParser

# 创建窗口解析器
parser = SentenceWindowNodeParser.from_defaults(
    window_size=3,  # 前后各保留3个句子
    window_metadata_key="window",
    original_text_metadata_key="original_text"
)

# 检索时:匹配单个句子(精确)
# 返回时:返回句子+前后3句(完整)

适用场景

  • • 连续的文本内容(文章、故事、说明文档)

  • • 句子之间有逻辑关联

  • • 需要上下文才能理解的内容

  • • 不适合:列表、表格等非连续内容

优点

  • • 实现简单,一行代码搞定

  • • 自动处理上下文扩展

  • • 不需要预先定义父子关系

缺点

  • • 窗口大小固定,不够灵活

  • • 可能包含无关句子

  • • 跨段落时可能引入噪声


方法2:父子文本块(Parent-Child Chunks)

原理:文档先切成大块(父),再把大块切成小块(子)。检索用子块,返回用父块。

通俗比喻

一本书(文档)
├─ 第一章(父块1000字)
│   ├─ 第1节(子块200字)← 检索匹配这里
│   ├─ 第2节(子块200字)
│   └─ 第3节(子块200字)
└─ 第二章(父块1000字)
    ├─ 第1节(子块200字)
    └─ 第2节(子块200字)

检索:匹配到"第一章-第1节"
返回:整个"第一章"的内容

代码示例

from langchain.retrievers import ParentDocumentRetriever
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 父块分割器(大块)
parent_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,    # 父块1000字
    chunk_overlap=200
)

# 子块分割器(小块)
child_splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,     # 子块200字
    chunk_overlap=50
)

# 创建检索器
retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,  # 子块存这里(用于检索)
    docstore=store,           # 父块存这里(用于返回)
    child_splitter=child_splitter,
    parent_splitter=parent_splitter
)

工作流程

1. 索引阶段:
   文档 → 切成父块 → 每个父块再切成子块
   子块向量化存入向量数据库
   父块原文存入文档存储

2. 检索阶段:
   查询 → 在子块中匹配(精确)
   找到子块 → 返回对应的父块(完整)

适用场景

  • • 有明确结构的文档(章节、段落)

  • • 需要灵活控制父子块大小

  • • 文档较长,需要分层管理

  • • 不适合:短文档、无结构文本

优点

  • • 父子块大小可自定义

  • • 检索精度高(子块小)

  • • 上下文完整(返回父块)

  • • 自动建立父子映射关系

缺点

  • • 需要两个存储(向量库+文档库)

  • • 实现相对复杂

  • • 存储成本较高


方法3:前后向扩展(Prev-Next Node)

原理:检索到目标块后,动态获取它前面和后面的文本块。

通俗比喻

文档切块:[块1][块2][块3][块4][块5]

检索匹配到:块3

固定扩展(num_nodes=1):
返回:[块2][块3][块4]

自动扩展(智能判断):
如果块2相关性高 → 返回:[块2][块3][块4]
如果块2相关性低 → 返回:[块3][块4]

代码示例

from llama_index.core.postprocessor import PrevNextNodePostprocessor

# 固定前后扩展
query_engine = index.as_query_engine(
    similarity_top_k=1,
    node_postprocessors=[
        PrevNextNodePostprocessor(
            docstore=docstore,
            num_nodes=2  # 前后各扩展2个块
        )
    ]
)

# 自动智能扩展
from llama_index.core.postprocessor import AutoPrevNextNodePostprocessor

query_engine = index.as_query_engine(
    similarity_top_k=1,
    node_postprocessors=[
        AutoPrevNextNodePostprocessor(
            docstore=docstore,
            num_nodes=3,  # 最多扩展3个块
            verbose=True
        )
    ]
)

适用场景

  • • 文档有明确的顺序关系

  • • 需要动态调整上下文范围

  • • 故事、流程、时间线类内容

  • • 不适合:无序文档、独立段落

优点

  • • 动态扩展,更智能

  • • 可以根据相关性决定扩展范围

  • • 适合有顺序的文档

缺点

  • • 需要维护块之间的顺序关系

  • • 自动扩展可能不可控

  • • 实现较复杂


性能对比

检索速度

句子窗口:    5/5 (最快,单次检索)
父子文本块:  4/5 (需要查两个存储)
前后向扩展:  3/5 (需要额外获取相邻块)

检索精度

句子窗口:    3/5 (窗口固定,可能有噪声)
父子文本块:  5/5 (子块小,匹配精确)
前后向扩展:  4/5 (动态扩展,较精确)

上下文完整性

句子窗口:    4/5 (固定窗口,基本完整)
父子文本块:  5/5 (返回整个父块,最完整)
前后向扩展:  4/5 (动态扩展,较完整)

实现难度

句子窗口:    1/5 (最简单)
父子文本块:  3/5 (中等)
前后向扩展:  4/5 (较复杂)

实战案例

案例1:游戏攻略问答(句子窗口)

场景:用户问"悟空有哪些形态?"

原文

游戏的战斗系统极具特色,采用了独特的"变身系统"。
悟空可以在战斗中变换不同形态。
每种形态都有其独特的战斗风格和技能组合。
金刚形态侧重力量型打击,带来压倒性的破坏力。
魔佛形态则专注法术攻击,能释放强大的法术伤害。
游戏世界中充满了标志性的神话角色。

检索过程

1. 匹配到句子:"悟空可以在战斗中变换不同形态。"
2. 窗口扩展(window_size=2):
   返回:
   "游戏的战斗系统极具特色,采用了独特的"变身系统"。
    悟空可以在战斗中变换不同形态。
    每种形态都有其独特的战斗风格和技能组合。
    金刚形态侧重力量型打击,带来压倒性的破坏力。
    魔佛形态则专注法术攻击,能释放强大的法术伤害。"

效果:完整回答了形态种类和特点。


案例2:技术文档问答(父子文本块)

场景:用户问"如何配置数据库连接?"

文档结构

第三章:数据库配置(父块)
├─ 3.1 连接字符串配置(子块)← 匹配这里
├─ 3.2 连接池设置(子块)
└─ 3.3 安全配置(子块)

检索过程

1. 子块检索:匹配到"3.1 连接字符串配置"
2. 返回父块:整个"第三章:数据库配置"
3. 包含内容:
   - 连接字符串配置
   - 连接池设置
   - 安全配置
   - 完整的配置示例

效果:不仅回答了连接配置,还提供了相关的池设置和安全配置。


案例3:故事情节问答(前后向扩展)

场景:用户问"悟空从忘川寺获得记忆后发生了什么?"

文档结构

[块1] 悟空初醒...
[块2] 老僧建议去忘川寺...
[块3] 在忘川寺获得记忆...  ← 匹配这里
[块4] 老僧告诉悟空去业火山...
[块5] 寻找三昧火甲...

检索过程

1. 匹配到:块3(忘川寺获得记忆)
2. 自动扩展:
   - 检查块4相关性:高(后续情节)→ 包含
   - 检查块5相关性:高(继续情节)→ 包含
3. 返回:[块3][块4][块5]

效果:完整展示了获得记忆后的情节发展。


参数调优指南

句子窗口参数

SentenceWindowNodeParser.from_defaults(
    window_size=3,  # 关键参数
)

window_size 选择建议

window_size=1:  适合句子独立性强的文档
window_size=3:  推荐,适合大多数场景
window_size=5:  适合需要更多上下文的场景
window_size=10: 可能引入过多噪声,不推荐

实验方法

# 对比不同窗口大小
for size in [1, 3, 5, 10]:
    parser = SentenceWindowNodeParser.from_defaults(window_size=size)
    # 测试检索效果
    # 选择效果最好的size

父子块参数

# 父块大小
parent_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,    # 关键参数1
    chunk_overlap=200
)

# 子块大小
child_splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,     # 关键参数2
    chunk_overlap=50
)

大小比例建议

父子比例 = 父块大小 / 子块大小

比例 2:1  - 太接近,意义不大
比例 5:1  - 推荐(如 1000:200)
比例 10:1 - 可以,但父块可能太大

常用配置

# 配置1:标准文档
parent_size=1000, child_size=200

# 配置2:长文档
parent_size=2000, child_size=300

# 配置3:短文档
parent_size=500, child_size=100

前后向扩展参数

PrevNextNodePostprocessor(
    docstore=docstore,
    num_nodes=2  # 关键参数
)

num_nodes 选择建议

num_nodes=1:  最小扩展,适合块较大的情况
num_nodes=2:  推荐,平衡性能和效果
num_nodes=3:  适合需要更多上下文
num_nodes>3:  可能引入过多无关内容

最佳实践

实践1:根据文档类型选择方法

# 连续文本(文章、故事)
→ 使用句子窗口(简单高效)

# 结构化文档(技术文档、书籍)
→ 使用父子文本块(精确可控)

# 有序文档(流程、时间线)
→ 使用前后向扩展(动态智能)

实践2:组合使用多种方法

# 第一层:父子文本块(粗定位)
# 第二层:句子窗口(细扩展)

# 先用父子块定位到相关章节
# 再用句子窗口扩展具体句子

实践3:监控和调优

# 记录检索日志
def log_retrieval(query, retrieved_text, response):
    print(f"查询:{query}")
    print(f"检索文本长度:{len(retrieved_text)}")
    print(f"回答质量:{evaluate(response)}")

# 根据日志调整参数
# - 如果回答不完整 → 增大窗口/父块
# - 如果有太多无关内容 → 减小窗口/父块

常见问题

问题1:窗口太大导致噪声

症状:检索返回了很多无关内容,影响回答质量。

原因:window_size 设置过大。

解决方案

# 错误
window_size = 10  # 太大

# 正确
window_size = 3   # 适中

问题2:父子块比例不当

症状:检索效果不明显,和普通检索差不多。

原因:父块和子块大小太接近。

解决方案

# 错误
parent_size = 500
child_size = 400  # 比例1.25:1,太接近

# 正确
parent_size = 1000
child_size = 200  # 比例5:1,合适

问题3:检索速度慢

症状:查询响应时间长。

原因

  • • 父子块需要查两个存储

  • • 前后向扩展需要额外查询

解决方案

# 方案1:使用更快的向量数据库
# Milvus > FAISS > Chroma

# 方案2:减少扩展范围
num_nodes = 1  # 减少扩展块数

# 方案3:使用句子窗口(最快)

问题4:跨段落/章节的窗口问题

症状:窗口包含了不同段落/章节的内容,导致混乱。

原因:句子窗口不考虑文档结构。

解决方案

# 使用父子文本块,按段落/章节切分
parent_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    separators=["\n\n", "\n", "。"]  # 按段落分隔
)

进阶技巧

技巧1:动态窗口大小

根据查询类型动态调整窗口大小:

def get_window_size(query):
    if "详细" in query or "具体" in query:
        return 5  # 需要更多上下文
    elif "简单" in query or "概括" in query:
        return 2  # 需要较少上下文
    else:
        return 3  # 默认

技巧2:混合策略

结合多种方法的优势:

# 1. 用父子块粗定位
parent_results = parent_retriever.get_relevant_documents(query)

# 2. 在父块内用句子窗口细检索
for parent in parent_results:
    window_results = window_search(query, parent)

技巧3:上下文质量评估

自动评估返回的上下文质量:

def evaluate_context(query, context):
    # 检查上下文是否包含查询关键词
    # 检查上下文长度是否合适
    # 检查上下文连贯性
    return quality_score

相关知识点

本目录未涉及但相关的技术

1. 语义分块(Semantic Chunking)

概念:根据语义相似度切分文档,而不是固定大小。

优势

  • • 保持语义完整性

  • • 避免切断相关内容

参考:见 rag/06-文本切块/04-LangChain-语义分块-DeepSeek.py


2. 重叠窗口(Overlapping Windows)

概念:相邻文本块之间有重叠部分。

示例

块1: [句1][句2][句3][句4]
块2:       [句3][句4][句5][句6]  ← 重叠句3、句4
块3:             [句5][句6][句7][句8]

优势

  • • 避免关键信息被切断

  • • 提高检索召回率


3. 上下文压缩(Context Compression)

概念:检索到大块后,只提取与查询相关的部分。

流程

1. 检索大块(包含完整上下文)
2. 用LLM提取相关句子
3. 返回压缩后的上下文

优势

  • • 减少token消耗

  • • 提高回答精度


4. 自适应检索(Adaptive Retrieval)

概念:根据查询复杂度动态调整检索策略。

示例

if is_simple_query(query):
    # 简单查询:小块检索
    return small_chunk_retrieval(query)
else:
    # 复杂查询:大块检索
    return large_chunk_retrieval(query)

5. 多跳检索(Multi-hop Retrieval)

概念:多次检索,逐步扩展上下文。

流程

第1跳:检索初始相关块
第2跳:基于第1跳结果,检索相关的相邻块
第3跳:继续扩展...

适用场景

  • • 需要综合多个信息源

  • • 复杂推理问题


学习路径

  1. 1. 简易 RAG 学习

  2. 2. LCEL 语法学习

  3. 3. LangChain 读取数据

    1. 1. LangChain 读取文本数据

    2. 2. LangChain 读取图片数据

    3. 3. LangChain 读取 PDF 数据

    4. 4. LangChain 读取表格数据

  4. 4. 文本切块

  5. 5. 向量嵌入与检索

  6. 6. 向量存储

  7. 7. 索引算法详解

  8. 8. 搜索和度量方法

  9. 9. 检索前处理

  10. 10. 索引优化

    1. 1. 从小块到大上下文 ← 当前

    2. 2. 构建有层次的索引

    3. 3. 构建多表示的索引

  11. 11. 检索后处理

  12. 12. 响应生成

  13. 13. 系统评估

项目地址

本文所有代码示例都在 GitHub 开源:

https://github.com/zonezoen/refine-rag

欢迎 Star 和 Fork,一起学习 RAG 技术!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值