一、为什么分块是 RAG 的 "原罪"
1.1 固定大小分块的三大致命缺陷
现在使用的固定大小分块(如 512token / 块)是最简单也是最常用的分块方法,但它存在三个无法解决的根本性问题,这也是 90% 的 RAG 系统回答质量差的根源。
缺陷 1:上下文断裂
这是最常见也最严重的问题。当一个完整的语义单元被分割到两个不同的块中时,检索到任何一个块都无法获得完整的信息。
示例:
- 文档原文:"检索增强生成技术的核心优势在于它可以利用外部知识库来增强大模型的回答能力,从而有效减少幻觉问题。"
- 分块结果:
- 块 1:"检索增强生成技术的核心优势在于它可以利用外部知识库来增强大模型的回答能力,"
- 块 2:"从而有效减少幻觉问题。"
当用户问 "RAG 如何解决幻觉问题?" 时,系统可能只能检索到块 1,大模型看到的信息是不完整的,自然无法给出准确的回答。
缺陷 2:语义不完整
固定大小分块会将不同主题的内容强行合并到同一个块中,导致块的语义混杂,影响检索的准确性。
示例:
- 一个 512token 的块中可能同时包含 "JVM 内存结构" 和 "JVM 垃圾回收算法" 两个完全不同的主题
- 当用户查询 "JVM 内存结构" 时,这个块会被检索到,但其中一半的内容是无关的
- 这些无关内容会作为噪声进入上下文,降低大模型的回答质量
缺陷 3:粒度不匹配
不同的查询需要不同粒度的信息。有些查询只需要一个句子就能回答,而有些查询需要整个段落甚至多个段落的信息。固定大小分块无法适应这种粒度差异。
示例:
- 查询 "什么是 RAG?":只需要一个定义句子
- 查询 "RAG 的工作流程是什么?":需要一个完整的段落
- 查询 "如何构建一个生产级 RAG 系统?":需要多个段落甚至整个章节
固定大小分块要么提供的信息太少,无法回答复杂问题;要么提供的信息太多,包含大量噪声。
1.2 工业界三大主流高级检索架构
为了解决固定大小分块的问题,工业界经过多年的实践,总结出了三种主流的高级检索架构,它们各有优缺点,适用于不同的场景。
| 检索架构 | 核心思想 | 解决的问题 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 句子窗口检索 | 用细粒度的句子进行检索,返回句子前后 N 个句子的上下文 | 上下文断裂 | 实现简单、效果好、速度快 | 窗口大小固定,无法自适应 | 大多数通用场景 |
| 父文档检索 | 将文档分成小的子块用于检索,返回整个父块 | 语义不完整、粒度不匹配 | 可以获得完整的语义单元 | 父块大小固定,可能包含无关内容 | 结构化文档、技术手册 |
| 分层检索 | 先检索文档摘要,再检索具体内容块 | 大规模文档库的检索效率 | 可以处理百万级以上的文档 | 实现复杂、需要额外生成摘要 | 大规模知识库、企业文档库 |
1.3 句子窗口检索:最实用的高级检索方法
句子窗口检索是目前工业界最常用的高级检索方法,它的实现最简单,效果提升最明显,几乎适用于所有场景。
工作原理
- 分块阶段:将文档分割成单个句子(或非常小的块,如 128token)
- 检索阶段:用这些细粒度的句子进行检索,找到最相关的句子
- 返回阶段:返回该句子前后 N 个句子的上下文,而不仅仅是句子本身
示例:
- 分块:将文档分割成 100 个句子
- 检索:找到最相关的句子是第 35 句
- 返回:返回第 30 句到第 40 句的上下文(窗口大小为 10)
核心优势
- 完美解决上下文断裂问题:相关句子的上下文会被完整返回
- 检索精度更高:细粒度的句子检索可以更准确地找到相关信息
- 实现简单:只需要在现有分块和检索逻辑上做少量修改
- 速度快:细粒度的向量检索速度比粗粒度更快
关键参数:窗口大小
窗口大小是句子窗口检索最重要的参数,它决定了返回的上下文长度。
- 窗口太小:仍然会有上下文断裂问题
- 窗口太大:会引入过多的无关内容
- 工业界最佳实践:窗口大小设置为 5-10 个句子,或 512-1024 个 token
1.4 父文档检索:结构化文档的最佳选择
父文档检索特别适合处理结构化文档,如技术手册、API 文档、书籍等,这些文档有明确的章节和段落结构。
工作原理
- 分块阶段 :
- 将文档分成大的父块(如每个段落或每个小节,512-2048token)
- 将每个父块再分成小的子块(如每个句子或 128token)
- 建立子块到父块的映射关系
- 检索阶段:用细粒度的子块进行检索,找到最相关的子块
- 返回阶段:返回子块对应的整个父块,而不仅仅是子块本身
示例:
- 父块:一个完整的段落(1000token)
- 子块:将这个段落分成 5 个句子(每个句子约 200token)
- 检索:找到最相关的子块是第 3 个句子
- 返回:返回整个段落(1000token)
核心优势
- 获得完整的语义单元:返回的是一个完整的段落或小节,语义完整
- 粒度匹配更好:用细粒度的子块检索,用粗粒度的父块回答
- 适合结构化文档:可以利用文档的天然结构进行分块
关键参数:父子块大小比
父子块大小比是父文档检索最重要的参数,它决定了检索精度和上下文完整性的平衡。
- 工业界最佳实践:父子块大小比设置为 4:1 到 8:1
- 例如:父块 1024token,子块 128-256token
1.5 分层检索:大规模文档库的解决方案
分层检索主要用于处理百万级以上的大规模文档库,它通过多级检索来提升检索效率和效果。
工作原理
- 索引阶段 :
- 为每个文档生成一个摘要
- 构建摘要层向量库和内容层向量库
- 检索阶段 :
- 第一层:检索摘要层,找到最相关的几个文档
- 第二层:在这些相关文档的内容层中进行细粒度检索
- 返回阶段:返回内容层的检索结果
核心优势
- 检索效率高:先在摘要层进行粗筛,大大减少了需要处理的文档数量
- 可以处理大规模文档库:支持百万级甚至千万级的文档
- 效果更好:摘要层可以更好地捕捉文档的整体主题
局限性
- 实现复杂:需要额外生成文档摘要,维护两个向量库
- 摘要质量影响整体效果:如果摘要生成得不好,会导致相关文档被过滤掉
- 不适合小规模文档库:对于几千个文档的小规模场景,反而会增加复杂度
二、从零构建高级检索架构
2.1 重构文档分块器:支持父子块关联结构
高级检索架构的基础是支持父子块关联的分块器。我们需要重构原来的简单分块器,使其能够生成父子块结构,并建立子块到父块的映射关系。
第一步:实现句子级分块
首先,我们需要实现一个能够将文档分割成句子的分块器。我们将使用nltk库的句子分割器,它对中文和英文都有很好的支持。
python
import nltk
from nltk.tokenize import sent_tokenize
import uuid
# 下载nltk的句子分割模型(第一次运行需要)
nltk.download('punkt')
def split_into_sentences(text: str) -> list:
"""将文本分割成句子"""
# 处理中文句子分割
text = text.replace('。', '。\n').replace('?', '?\n').replace('!', '!\n')
sentences = sent_tokenize(text)
# 过滤空句子
sentences = [s.strip() for s in sentences if s.strip()]
return sentences
第二步:实现父子块分块器
接下来,我们实现一个通用的父子块分块器,它可以将文档分割成父块和子块,并建立它们之间的映射关系。
python
def chunk_document_with_parent_child(
text: str,
parent_chunk_size: int = 1024,
child_chunk_size: int = 128,
overlap: int = 0,
metadata: dict = None
) -> tuple[list, dict]:
"""
将文档分割成父子块结构
:param text: 文档文本
:param parent_chunk_size: 父块大小(token数)
:param child_chunk_size: 子块大小(token数)
:param overlap: 块重叠大小
:param metadata: 文档元数据
:return: (所有块列表, 子块到父块的映射字典)
"""
if metadata is None:
metadata = {}
# 第一步:将文档分割成句子
sentences = split_into_sentences(text)
# 第二步:将句子组合成父块
parent_chunks = []
current_parent = []
current_parent_length = 0
for sentence in sentences:
sentence_length = len(sentence) // 4 # 简单估算token数:1个token≈4个字符
if current_parent_length + sentence_length > parent_chunk_size and current_parent:
# 父块已满,保存并新建
parent_text = " ".join(current_parent)
parent_chunks.append(parent_text)
current_parent = []
current_parent_length = 0
current_parent.append(sentence)
current_parent_length += sentence_length
# 保存最后一个父块
if current_parent:
parent_text = " ".join(current_parent)
parent_chunks.append(parent_text)
# 第三步:将每个父块分割成子块,并建立映射
all_chunks = []
child_to_parent = {}
for parent_idx, parent_text in enumerate(parent_chunks):
parent_id = str(uuid.uuid4())
# 添加父块到所有块列表
parent_chunk = {
"id": parent_id,
"text": parent_text,
"metadata": {
**metadata,
"chunk_type": "parent",
"parent_idx": parent_idx
}
}
all_chunks.append(parent_chunk)
# 将父块分割成子块
child_sentences = split_into_sentences(parent_text)
current_child = []
current_child_length = 0
for child_sentence in child_sentences:
child_sentence_length = len(child_sentence) // 4
if current_child_length + child_sentence_length > child_chunk_size and current_child:
# 子块已满,保存并新建
child_text = " ".join(current_child)
child_id = str(uuid.uuid4())
child_chunk = {
"id": child_id,
"text": child_text,
"metadata": {
**metadata,
"chunk_type": "child",
"parent_id": parent_id,
"parent_idx": parent_idx
}
}
all_chunks.append(child_chunk)
# 建立子块到父块的映射
child_to_parent[child_id] = parent_id
current_child = []
current_child_length = 0
current_child.append(child_sentence)
current_child_length += child_sentence_length
# 保存最后一个子块
if current_child:
child_text = " ".join(current_child)
child_id = str(uuid.uuid4())
child_chunk = {
"id": child_id,
"text": child_text,
"metadata": {
**metadata,
"chunk_type": "child",
"parent_id": parent_id,
"parent_idx": parent_idx
}
}
all_chunks.append(child_chunk)
child_to_parent[child_id] = parent_id
return all_chunks, child_to_parent
代码解释
- 我们首先将文档分割成句子,这是所有高级分块的基础
- 然后将句子组合成大的父块(默认 1024token)
- 再将每个父块分割成小的子块(默认 128token)
- 为每个块生成唯一的 ID,并在子块的元数据中记录对应的父块 ID
- 最后返回所有块的列表和子块到父块的映射字典
2.2 实现句子窗口检索器
句子窗口检索器的核心是:检索到相关的子块后,返回该子块前后 N 个句子的上下文。
python
def sentence_window_retrieval(
self,
query: str,
top_k: int = 5,
window_size: int = 5
) -> list:
"""
句子窗口检索
:param query: 用户查询
:param top_k: 返回结果数量
:param window_size: 窗口大小(前后各window_size个句子)
:return: 检索结果列表
"""
# 第一步:检索最相关的子块
child_results = self.semantic_search(query, top_k=top_k*2)
# 第二步:为每个子块扩展上下文窗口
window_results = []
for child_result in child_results:
# 获取子块所在的父块
parent_id = child_result["metadata"]["parent_id"]
parent_chunk = next((c for c in self.chunks if c["id"] == parent_id), None)
if not parent_chunk:
continue
# 将父块分割成句子
parent_sentences = split_into_sentences(parent_chunk["text"])
# 找到子块在父块中的位置
child_text = child_result["text"]
child_sentence_idx = -1
for i, sentence in enumerate(parent_sentences):
if child_text in sentence or sentence in child_text:
child_sentence_idx = i
break
if child_sentence_idx == -1:
continue
# 计算窗口的起始和结束位置
start_idx = max(0, child_sentence_idx - window_size)
end_idx = min(len(parent_sentences), child_sentence_idx + window_size + 1)
# 提取窗口内的句子
window_sentences = parent_sentences[start_idx:end_idx]
window_text = " ".join(window_sentences)
# 创建窗口结果
window_result = {
"id": child_result["id"],
"text": window_text,
"metadata": {
**child_result["metadata"],
"window_size": window_size,
"original_sentence": child_text,
"sentence_idx": child_sentence_idx
},
"score": child_result["score"]
}
window_results.append(window_result)
# 去重并按得分排序
seen_texts = set()
unique_results = []
for result in window_results:
if result["text"] not in seen_texts:
seen_texts.add(result["text"])
unique_results.append(result)
# 按得分降序排序
unique_results.sort(key=lambda x: x["score"], reverse=True)
return unique_results[:top_k]
代码解释
- 首先用细粒度的子块进行检索,返回 Top-2*K 个结果(为了去重)
- 对于每个检索到的子块,找到它所在的父块
- 将父块分割成句子,找到子块对应的句子在父块中的位置
- 提取该句子前后
window_size个句子的上下文 - 最后去重并按得分排序,返回 Top-K 个结果
2.3 实现父文档检索器
父文档检索器的核心是:检索到相关的子块后,返回该子块对应的整个父块。
python
def parent_document_retrieval(
self,
query: str,
top_k: int = 5
) -> list:
"""
父文档检索
:param query: 用户查询
:param top_k: 返回结果数量
:return: 检索结果列表
"""
# 第一步:检索最相关的子块
child_results = self.semantic_search(query, top_k=top_k*2)
# 第二步:收集对应的父块
parent_results = []
seen_parent_ids = set()
for child_result in child_results:
parent_id = child_result["metadata"]["parent_id"]
if parent_id in seen_parent_ids:
continue
seen_parent_ids.add(parent_id)
# 获取父块
parent_chunk = next((c for c in self.chunks if c["id"] == parent_id), None)
if not parent_chunk:
continue
# 创建父块结果
parent_result = {
"id": parent_id,
"text": parent_chunk["text"],
"metadata": {
**parent_chunk["metadata"],
"child_id": child_result["id"],
"child_score": child_result["score"]
},
"score": child_result["score"]
}
parent_results.append(parent_result)
if len(parent_results) >= top_k:
break
return parent_results
代码解释
- 首先用细粒度的子块进行检索,返回 Top-2*K 个结果
- 对于每个检索到的子块,找到它对应的父块
- 去重(避免同一个父块被多次返回)
- 最后返回 Top-K 个父块结果
2.4 统一检索接口
为了方便使用和对比不同的检索策略,我们需要统一检索接口,支持一键切换不同的检索方法。
在HybridRetriever类中添加以下方法:
python
def advanced_search(
self,
query: str,
top_k: int = 5,
retrieval_method: str = "sentence_window",
window_size: int = 5
) -> list:
"""
统一高级检索接口
:param query: 用户查询
:param top_k: 返回结果数量
:param retrieval_method: 检索方法:"sentence_window" / "parent_document" / "hybrid"
:param window_size: 句子窗口大小(仅sentence_window方法有效)
:return: 检索结果列表
"""
if retrieval_method == "sentence_window":
return self.sentence_window_retrieval(query, top_k, window_size)
elif retrieval_method == "parent_document":
return self.parent_document_retrieval(query, top_k)
elif retrieval_method == "hybrid":
# 混合使用句子窗口和父文档检索
sw_results = self.sentence_window_retrieval(query, top_k//2, window_size)
pd_results = self.parent_document_retrieval(query, top_k//2)
# 合并结果并去重
all_results = sw_results + pd_results
all_results.sort(key=lambda x: x["score"], reverse=True)
seen_texts = set()
unique_results = []
for result in all_results:
if result["text"] not in seen_texts:
seen_texts.add(result["text"])
unique_results.append(result)
return unique_results[:top_k]
else:
raise ValueError(f"不支持的检索方法:{retrieval_method}")
2.5 集成到 RAG 核心类
最后,我们需要将高级检索集成到 RAG 核心类中,替换原来的简单检索。
打开rag_core.py,找到调用检索的地方,修改为:
python
def query(self, question, top_k=5, stream=False, max_context_tokens=12000):
processed_question = self._preprocess_query(question)
if not processed_question:
return "请输入有效的问题。"
# 使用高级检索
retrieved_docs = self.retriever.advanced_search(
query=processed_question,
top_k=top_k,
retrieval_method="sentence_window", # 可以切换为"parent_document"或"hybrid"
window_size=5
)
# 重排序
reranked_docs = self.retriever.reranker.rerank(processed_question, retrieved_docs, top_k=top_k)
print(f"\n📝 最终送入大模型的文档数量:{len(reranked_docs)}")
if not reranked_docs:
return "抱歉,知识库中没有找到相关信息,无法回答您的问题。"
# 后续的上下文拼接和大模型生成代码保持不变
# ...