欢迎来到啾啾的博客🐱。
记录学习点滴。分享工作思考和实用技巧,偶尔也分享一些杂谈💬。
有很多很多不足的地方,欢迎评论交流,感谢您的阅读和评论😄。
目录
1 引言
在LangChain快速筑基系列中,我们已经了解了RAG基本流程。
RAG的精髓,就是“先查资料再回答”的流程。它把LLM从一个“凭记忆回答者”变成了一个“有能力查阅资料的专家”。
而从本篇开始,我们将适当深入了解RAG具体细节。
2 分块:RAG流程反映的技术限制
2.1 为什么我们不能直接将500页的PDF文档,一次性喂给LLM?
A:最核心的原因是LLM的上下文窗口(Context Window)大小限制。
上下文窗口是LLM的“短期记忆”或“工作台”的大小。它限制了你 一次性 可以输入给模型(作为Prompt)和从模型获取(作为输出)的总文本量。这个量通常用 Token 来计算(一个Token约等于0.75个英文单词或半个汉字)。
DeepSeek文档显示其上下文限制为输出<=64k。
所以,RAG流程需要将文本进行分块处理。
其他原因还有:
提高检索精度
如果我们把一整本书作为一个“块”,然后问一个关于其中某个具体细节的问题。那么“整本书”这个块的向量,代表的是全书的“平均”语义。用一个非常具体的问题向量去匹配一个非常概括的“全书”向量,效果会很差,就像用“拿破仑在哪场战役中失败了?”去匹配《欧洲通史》这本书一样,匹配度很低。而如果我们把书切分成章节,那么问题就更容易精确地匹配到“滑铁卢战役”那一章。降低噪声
即使我们能把整本书都塞进上下文,但书中99%的内容都与用户的问题无关。把这些无关的“噪声”一起喂给LLM,会干扰它的注意力,可能导致它给出不相关或质量更差的答案。只给它最相关的几段文本,能让它更聚焦。
2.2 块分成多大合适?
这是一个典型的工程权衡。
A:太大会噪声太多,降低检索精度
如果块设置的过大,比如5000字符。那么块的信息将不精确、难以比较、充满“噪声”。
且在RAG的最后一步,我们会把检索到的块和问题一起发给LLM。发送的文本越长,消耗的Token就越多,API调用费用就越高,同时LLM生成答案的速度也越慢。
A:太小会上下文丢失,信息不全。
如果设置的过小,比如50字符。那么会带来上下文丢失 (Loss of Context)的问题。
因为一个独立的句子或短语往往无法完整地表达一个意思。
例如,“它主要通过分代假设来优化性能”这句话被切成了一个独立的块。
当系统检索到这个块时,它本身是有意义的,但LLM看到它时会非常困惑:“它”是谁?是JVM?是G1回收器?还是某个算法?因为失去了上一句话的铺垫,这个块的信息变得残缺不全。
通用的做法是切小块,然后进行“块重叠(Chunk Overlap)”。
块重叠:在切分时,让后一个块包含前一个块末尾的一部分内容。
举个例子:
假设块大小是100字符,重叠大小是20字符。
- 块1: 从字符1到字符100。
- 块2: 不再从字符101开始,而是从字符81开始,到字符180结束。
- 块3: 从字符161开始,到字符260结束。
这极大地保留了局部的上下文信息,提高了RAG系统的回答质量。
2.3 怎么判断分块的好坏?
Q:如何判定分块所带来的影响呢?是不是如果知识库中存在相关内容,但 RAG 却没检索到,就意味着分块没分好呢?
A:不一定,但分块是主要因素(剩下的因素还有Embedding模型与top_k参数)。
我们通过两个核心指标来衡量分块的好坏:
- 召回率 (Recall)
在所有相关的文本块中,我们的检索系统成功找回来了多少? - 精确率 (Precision)
在我们找回来的所有文本块中,有多少是真正相关的?
2.3.1 评估流程
我们不能凭感觉判断指标。
第一步:创建测试集
我们需要创建一个小型的、高质量的评测集。
这个集合包含成对的“问题”和“期望答案原文”。做法: 打开您的一个源文档(比如一个PDF),选中一段话,然后思考:“如果我想让RAG返回这段话,我应该问什么问题?”。把这个问题和这段原文记录下来。重复这个过程,创建几十个这样的问答对。
第二步:执行“仅检索”测试
对于测试集里的每一个问题,执行RAG的前半部分——检索。拿到返回的文本块列表。第三步:人工或自动评估
检查返回的文本块列表里,是否包含了您期望的“答案原文”。如果包含了 -> 这次测试的 召回成功。
如果没包含 -> 召回失败。
第四步:诊断失败案例
诊断流程如下:
- 找到“期望的答案原文”在分块后,到底属于哪个或哪几个文本块。
- 分析这个/这些文本块:它是不是太小/太大了?关键信息是不是正好被边界切断了?
- 如果分块看起来没问题,那就去比较“问题向量”和“答案块向量”的相似度得分。如果得分真的很低,那可能是Embedding模型的问题。
- 检查这个“答案块”在所有块的相似度排名中到底排第几。如果排在第5,而你只召回前3,那就是top_k的问题。
3 LangChain的文本分块处理
3.1 RecursiveCharacterTextSplitter
RecursiveCharacterTextSplitter,LangChain库提供的递归字符文本分割器。
它的工作逻辑是:
- 它会拿到一个“分隔符”列表,默认是 [“\n\n”, “\n”, " ", “”] (两个换行符、一个换行符、空格、空字符串)。
- 它首先尝试用第一个分隔符 \n\n (通常代表段落) 来分割文本。
- 分割完后,它会检查每个分割出的块,如果哪个块的长度仍然超过了我们设定的 chunk_size,它就会对 那个过长的块,使用 下一个 分隔符 \n (通常代表句子) 来进行二次分割。
- 这个过程会 递归 地进行下去,直到所有块的长度都小于 chunk_size。
这种方法的绝妙之处在于,它会 优先尝试在最自然的语义边界(段落、句子)上进行分割,只有在万不得已时,才会粗暴地按空格或字符来切分。这最大限度地保留了文本的原始结构和语义完整性。
3.1.1 RecursiveCharacterTextSplitter分割文本
假设我们有以下这段关于RAG的介绍文本:
text_to_split = """
RAG的核心思想是开卷考试。您可以把传统的LLM想象成一个“闭卷考试”,它只能根据自己脑海里预先训练好的知识来回答问题。如果问它一个最新的、或者私有的信息,它就会说“我不知道”。
而RAG (Retrieval-Augmented Generation),就是把这个过程变成了一场“开卷考试”。在回答你的问题之前,它会先去一个我们指定的“书架”(也就是你的知识库)上,快速“检索”到最相关的几页“书”(也就是文本片段),然后把这些内容和你的问题一起,作为参考资料交给LLM,让它“阅读并总结”出答案。
"""
使用RecursiveCharacterTextSplitter进行分割
# 1. 从langchain库中导入我们需要的分割器
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_to_split = """
RAG的核心思想是开卷考试。您可以把传统的LLM想象成一个“闭卷考试”,它只能根据自己脑海里预先训练好的知识来回答问题。如果问它一个最新的、或者私有的信息,它就会说“我不知道”。
而RAG (Retrieval-Augmented Generation),就是把这个过程变成了一场“开卷考试”。在回答你的问题之前,它会先去一个我们指定的“书架”(也就是你的知识库)上,快速“检索”到最相关的几页“书”(也就是文本片段),然后把这些内容和你的问题一起,作为参考资料交给LLM,让它“阅读并总结”出答案。
"""
# 2. 创建一个分割器实例
# chunk_size: 每个块的最大长度(字符数)。这是一个核心参数。
# chunk_overlap: 块之间的重叠长度。这是防止上下文断裂的关键。
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=100, # 我们故意设置一个较小的值,以便清晰地看到分割效果
chunk_overlap=20,
length_function=len, # 使用Python内置的len函数来计算长度
is_separator_regex=False, # 分隔符不是正则表达式
)
# 3. 使用分割器来创建文本块
chunks = text_splitter.create_documents([text_to_split])
# 4. 打印结果,看看发生了什么
for i, chunk in enumerate(chunks):
print(f"--- 块 {i+1} ---")
print(chunk.page_content) # .page_content 属性包含了块的文本内容
print(f"长度: {len(chunk.page_content)}\n")
输出如下:
--- 块 1 ---
RAG的核心思想是开卷考试。您可以把传统的LLM想象成一个“闭卷考试”,它只能根据自己脑海里预先训练好的知识来回答问题。如果问它一个最新的、或者私有的信息,它就会说“我不知道”。
长度: 89
--- 块 2 ---
而RAG (Retrieval-Augmented
长度: 25
--- 块 3 ---
Generation),就是把这个过程变成了一场“开卷考试”。在回答你的问题之前,它会先去一个我们指定的“书架”(也 就是你的知识库)上,快速“检索”到最相关的几页“书”(也就是文本片段),然后把这些
长度: 99
--- 块 4 ---
几页“书”(也就是文本片段),然后把这些内容和你的问题一起,作为参考资料交给LLM,让它“阅读并总结”出答案 。
长度: 55
3.1.2 RecursiveCharacterTextSplitter块重叠问题
RecursiveCharacterTextSplitter 的默认行为是尊重语义边界,这里我们设置了chunk_overlap=20,但是输出的结果并没有重叠部分。
因为chunk_overlap 参数只在 一个连续的文本块因为过长而被“切开”时,才会在切开的两个新块之间生效。
在我们的文本中,块1和块2是被两个\n\n
分开段落,因此他们之间不应用重叠。
如何让他们块重叠来增强上下文的联系呢?
有两种主流方法:
- 预处理文本,移除天然隔离符
最简单直接的方法,就是在分割之前,先把那些它优先使用的分隔符(主要是换行符)替换成普通字符(比如空格)。这样,在分割器看来,整段文本就是一块连续的“铁板”,它不得不进行“硬切”,从而触发重叠逻辑。
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_to_split = """
RAG的核心思想是开卷考试。您可以把传统的LLM想象成一个“闭卷考试”,它只能根据自己脑海里预先训练好的知识来回答问题。如果问它一个最新的、或者私有的信息,它就会说“我不知道”。
而RAG (Retrieval-Augmented Generation),就是把这个过程变成了一场“开卷考试”。在回答你的问题之前,它会先去一个我们指定的“书架”(也就是你的知识库)上,快速“检索”到最相关的几页“书”(也就是文本片段),然后把这些内容和你的问题一起,作为参考资料交给LLM,让它“阅读并总结”出答案。
"""
# --- 核心修改在这里 ---
# 1. 预处理文本:将所有换行符替换为空格
processed_text = text_to_split.replace("\n", " ")
# 2. 创建分割器实例 (参数不变)
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=100,
chunk_overlap=20,
length_function=len,
is_separator_regex=False,
)
# 3. 使用预处理后的文本进行分割
# 注意:create_documents需要一个列表,所以我们传入 [processed_text]
chunks = text_splitter.create_documents([processed_text])
# 4. 打印结果
for i, chunk in enumerate(chunks):
print(f"--- 块 {i+1} ---")
print(chunk.page_content)
print(f"长度: {len(chunk.page_content)}\n")
- 自定义分隔符列表
另一种更“高级”的方法是,在创建分割器时,直接告诉它不要使用默认的 [“\n\n”, “\n”, " ", “”] 分隔符列表,而是使用一个我们自己定义的、在文本中几乎不存在的分隔符。
# --- 核心修改在这里 ---
# 1. 创建分割器时,传入一个几乎不可能存在的分隔符列表
text_splitter_custom = RecursiveCharacterTextSplitter(
chunk_size=100,
chunk_overlap=20,
separators=["<|IMPOSSIBLE_SEPARATOR|>"] # 提供一个自定义的分隔符列表
)
# 2. 直接对原始文本进行分割
chunks_custom = text_splitter_custom.create_documents([text_to_split])
3.2 分块中的英文处理
RecursiveCharacterTextSplitter原理上是依靠分隔符按分块大小分割,而中英文书写习惯不一样,英文有大量空格而中文没有,因此对于中英混合的文本处理,RecursiveCharacterTextSplitter有着天然的局限性。
为此,我们需要在更有意义的边界上进行分割。而最自然、最有效的语义边界就是 句子。
使用基于句子边界的分割器。
利用成熟的自然语言处理(NLP)库,如 NLTK (Natural Language Toolkit),来准确地识别句子边界,然后再根据句子列表来组织我们的文本块。