分块的艺术:提升 RAG 架构中的 AI 性能

430 阅读14分钟

聪明人很懒。 他们找到解决复杂问题的最有效方法,以最少的努力获得最大的成果。

在生成式人工智能应用中,这种效率是通过分块实现的。就像将一本书分成几章使其更易于阅读一样,分块将重要的文本划分为更小、更易于管理的部分,使其更易于处理和理解。

在探索分块机制之前,必须了解该技术运行的更广泛的框架:检索增强生成或RAG。

什么是 RAG?

什么是检索增强生成

检索增强生成 (RAG) 是一种将检索机制与大型语言模型 (LLM 模型) 相结合的方法。它使用检索到的文档来增强 AI 功能,以生成更准确、上下文更丰富的响应。

引入分块

什么是分块

分块是将大段文本分解成更小、更易于管理的块。此过程主要分为两个阶段:

  • 数据准备:将可靠的数据源分割成分块文档并存储在数据库中。如果在分块内生成嵌入,则数据库可以是向量存储。
  • 检索:当用户提出问题时,系统会使用向量搜索、全文搜索或两者结合的方式搜索文档块。此过程会识别并检索与用户查询最相关的块。

为什么分块在 RAG 架构中至关重要

分块在 RAG 架构中绝对必不可少,因为它是决定 Gen AI 应用程序准确性的第一个元素。

  1. 词块应较小以提高准确性: 词块划分使系统能够索引和搜索较小的文本片段,从而提高查找相关文档的准确性。进行查询时,系统可以快速找到最相关的词块,从而提高检索过程的准确性。
  2. 块应该很大以增强上下文生成: 块不应该很小。通过使用较小的块,生成模型可以更好地理解和利用每个片段提供的上下文。这会产生更连贯和上下文准确的响应,因为模型可以利用特定的相关信息,而不是筛选大型未分割的文档。
  3. 可扩展性和性能: 分块可以更可扩展、更高效地处理大型数据集。它通过将数据分解为可管理的部分来减少计算负载,这些部分可以并行处理,从而提高 RAG 系统的整体性能。但是,可扩展性应通过以下方式来确保

分块是确保 RAG 系统稳健、高效和可扩展的技术必需品和战略方法。它可提高检索准确性、处理效率和资源利用率,对 RAG 应用的成功起着至关重要的作用。

改进组块划分的技巧

有多种技术可以改善分块,从基本方法到高级方法:

  • 固定字符大小: 简单直接,将文本分成固定数量字符的块。
  • 递归字符文本分割: 使用空格或标点符号等分隔符来创建更具上下文意义的块。
  • 特定文档的拆分: 根据文档类型(例如 PDF 或 Markdown 文件)定制分块方法。
  • 语义分割: 使用嵌入根据语义内容对文本进行分块。
  • 代理分割: 采用大型语言模型根据内容和上下文确定最佳分块。

通过采用这些技术,RAG 系统可以获得更高的性能和更准确的结果,巩固其作为 AI 中重要工具的地位。

固定字符大小

固定字符大小分块是拆分文本的最基本方法。这种方法涉及将文本分成预定字符数的块,而不管内容如何。这种方法很简单,但缺乏对文本结构和上下文的考虑,这可能导致块的意义不大。

固定大小分块

优点:

  • 简单: 易于实现且需要最少的计算资源。
  • 一致性: 产生均匀的块,简化下游处理。

缺点:

  • 忽略上下文: 忽略文本的结构和含义,导致信息碎片化。
  • 效率低下: 可能会切断重要的内容,需要额外的处理才能重新组合有意义的信息。

下面是如何使用前面提供的代码实现固定字符大小分块的示例:

# 将示例文本分块
text = "This is the text I would like to ch up. It is the example text for this exercise." 

# 设置块大小
chunk_size = 35 
# 初始化一个列表来保存块 chunks
 = [] 
# 遍历文本以创建块
for i in  range ( 0 , len (text), chunk_size): 
    chunk = text[i:i + chunk_size] 
    chunks.append(chunk) 
# 显示块
print (chunks) 
# 输出:['This is the text I would like to ch', 'unk up. It is the example text for ', 'this exercise']

使用 LangChainCharacterTextSplitter来实现相同的结果:

from langchain.text_splitter import CharacterTextSplitter 

# 使用指定的块大小初始化文本分割器
text_splitter = CharacterTextSplitter(chunk_size= 35 , chunk_overlap= 0 , Separator= '' , strip_whitespace= False ) 
# 使用文本分割器创建文档
documents = text_splitter.create_documents([text]) 
# 显示创建的文档
for doc in documents: 
    print (doc.page_content) 
# 输出:
# 这是我想要分割的文本。它是本练习
的示例文本

固定字符大小分块是一种简单但基础的技术,通常在转向更复杂的方法之前用作基准。

递归字符文本分割

递归字符文本分割是一种更高级的技术,它考虑了文本的结构。它使用一系列分隔符以递归方式将文本分成块,确保块更有意义且与上下文更相关。

递归字符分块

在上面的例子中,块大小为 30 个字符,重叠为 20 个字符,RecursiveCharacterTextSplitter 将尝试在保持逻辑边界的同时拆分文本。然而,这也表明,由于块大小较小,它仍可能在单词或句子中间拆分,这根本不是最佳选择。

优点:

  • 改进的上下文: 此方法使用段落或句子等分隔符保留文本的自然结构。
  • 灵活性: 允许改变块大小和重叠,从而更好地控制分块过程。

缺点:

  • 块大小很重要: 它应该是可管理的,但仍然包含至少一个短语或更多。否则,我们需要在检索块时获得精度。
  • 性能开销: 由于递归拆分和处理多个分隔符,需要更多计算资源。与固定大小的块相比,我们生成的块更多。

以下是在 Langchain 中如何实现递归字符文本拆分的示例:

%pip 安装 -qU langchain 文本拆分器

如果您还没有安装 long-chain-text-splitters 库,请首先安装。

从langchain_text_splitters导入RecursiveCharacterTextSplitter 
# 将示例文本分成几块
text = """
最初在古希腊举行的奥运会于 1896 年恢复,
从此成为世界最重要的体育竞赛,汇集了
来自世界各地的运动员。
""" 
# 使用指定的块大小初始化递归字符文本分割器
text_splitter = RecursiveCharacterTextSplitter( 
    # 设置一个非常小的块大小,只是为了显示。
     chunk_size= 30chunk_overlap= 20length_function= len,
    is_separator_regex= False,
) 

# 使用文本分割器创建文档
documents = text_splitter.create_documents([text]) 
# 显示创建的文档
for doc in documents: 
    print (doc.page_content) 
# 输出:
# “奥运会最初” 
# “在古希腊举行,” 
# “于 1896 年恢复” 
# “从那时起成为世界上” 
#“世界最重要的体育赛事” 
#“比赛,汇集” #“来自世界各地的
运动员。”

在这种方法中,文本首先按较大的结构(如段落)进行拆分,如果块仍然太大,则使用较小的结构(如句子)进一步拆分。每个块都保留有意义的上下文,避免切断重要信息。

递归字符文本分割在简单性和复杂性之间取得了平衡,提供了一种尊重文本固有结构的强大分块方法。

特定文档的拆分

特定于文档的拆分将分块过程定制为不同的文档类型,例如 Markdown 文件、Python 脚本、JSON 文档或 HTML,确保以最适合其内容和结构的方式拆分每种类型。

例如,Markdown 广泛应用于 GitHub、Medium 和 Confluence 等平台,使其成为 RAG 系统中提取的自然选择,因为干净的结构化数据对于生成准确的响应至关重要。

此外,特定语言的分割器适用于各种编程语言,包括 C++、Go、Java、Python 等,确保有效地对代码进行分块以进行分析和检索。

文档特定拆分——Markdown 文件

优点:

  • 相关性: 使用最合适的方法拆分不同类型的文档,同时保留其逻辑结构。
  • 精确度: 根据每种文档类型的独特特征定制分块过程。

缺点:

  • 实现复杂: 针对不同类型的文档,需要不同的分块策略和库。
  • 维护: 由于方法的多样性,维护比较复杂。

以下是如何实现 Markdown 和 Python 文件文档特定拆分的示例:

Markdown 拆分

从langchain.text_splitter导入MarkdownTextSplitter 
# 示例 Markdown 文本
markdown_text = """ 
# 加州的乐趣
## 驾车
试着沿着 1 号公路开车去圣地亚哥
### 食物
确保在那里吃一个墨西哥卷饼
## 徒步
旅行 去优胜美地
""" 
# 初始化 Markdown 文本分割器
splitter = MarkdownTextSplitter(chunk_size= 40 , chunk_overlap= 0 ) 
# 使用文本分割器创建文档
documents = splitter.create_documents([markdown_text]) 
# 显示创建的文档
for doc in documents: 
    print (doc.page_content) 
# 输出:
# # 加州的乐趣\n\n## 驾车
# 试着沿着 1 号公路开车去圣地亚哥
# ### 食物
# 确保在
那里吃一个墨西哥卷饼
# ## 徒步旅行\n\n去优胜美地

Python 代码分割

from langchain.text_splitter import PythonCodeTextSplitter 
# 示例 Python 代码
python_text = """ 
class Person: 
    def __init__(self, name, age): 
        self.name = name 
        self.age = age 
p1 = Person("John", 36) 
for i in range(10): 
    print(i) 
""" 
# 初始化 Python 代码文本分割器
python_splitter = PythonCodeTextSplitter(chunk_size= 100 , chunk_overlap= 0 ) 
# 使用文本分割器创建文档
documents = python_splitter.create_documents([python_text]) 
# 显示创建的文档
for doc in documents: 
    print (doc.page_content) 
# 输出:
# class Person:\n def __init__(self, name, age):\n self.name = name\n self.age = age 
# p1 = Person("John", 36)\n\nfor i in range(10):\n print(i)

特定于文档的拆分保留了文档的逻辑结构,使块更有意义且上下文准确。例如,Markdown 文件中的标题和部分是分开的,而 Python 代码中使用类和函数。

该方法通过维护不同文档类型的完整性来增强系统检索和生成相关响应的能力,从而提高 RAG 系统的整体性能和准确性。

语义分割

与以前具有任意长度或句法规则的拆分方法不同,语义拆分通过使用文本的含义来确定块边界,将块拆分提升到一个新的水平。

该方法利用嵌入对语义相似的内容进行分组,确保每个块包含上下文一致的信息。

语义分块工作流程

上图展示了语义分块的工作流程,首先是句子拆分,然后生成嵌入,最后根据相似性对句子进行分组。该过程确保分块在语义上连贯,从而提高信息检索的相关性和准确性。

我们通过一个例子看看这个方法的输出。

语义分块示例

此图提供了一个实际示例,说明如何使用余弦相似度将句子分组。主题相关的句子被分组,而含义不同的句子则分开。视觉解释阐明了如何应用语义分块来保持文本的上下文和连贯性。

优点:

  • 上下文相关性: 确保块包含语义相似的内容,提高信息检索和生成的准确性。
  • 动态适应性: 能够根据含义而不是严格的规则来适应各种文本结构和内容类型。

缺点:

  • 计算开销: 需要额外的计算资源来生成和比较嵌入。
  • 复杂性: 与简单的拆分方法相比,实现起来更为复杂。

以下是如何使用嵌入实现语义分割的示例。此代码来自 Greg Kamradt 的笔记本:5_Levels_Of_Text_Splitting

from sklearn.metrics.pairwise import cosine_similarity 
from langchain.embeddings import OpenAIEmbeddings 
import re 
# Sample text
 text = """
我小时候不理解的最重要的事情之一是绩效回报的超线性程度。
老师和教练含蓄地告诉我们回报是线性的。“你付出多少就会得到多少”,我听过一千遍了。他们的本意是好的,但这很少是真的。如果你的产品只有竞争对手的一半好,你就不会得到一半的客户。你没有客户,你就会破产。
显然,在商业中,绩效回报是超线性的。有些人认为这是资本主义的缺陷,如果我们改变规则,它就不再是事实了。但绩效回报的超线性是世界的一个特征,而不是我们发明的规则的产物。我们在名誉、权力、军事胜利、知识甚至对人类的利益中看到了同样的模式。在所有这些中,富人越来越富有。
""" 
# 将文本拆分成句子
sentence = re.split( r'(?<=[.?!])\s+' , text) 
sentence = [{ 'sentence' : x, 'index' : i} for i, x in  enumerate (sentences)] 
# 合并上下文的句子
def  Combine_sentences ( Sentences, buffer_size= 1 ): 
    for i in  range ( len (sentences)): 
        combined_sentence = '' 
        for j in  range (i - buffer_size, i): 
            if j >= 0 : 
                combined_sentence += sentence[j][ 'sentence' ] + ' '
         combined_sentence += sentence[i][ 'sentence' ] 
        for j in  range (i + 1 , i + 1 + buffer_size): 
            if j < len (sentences):
                combined_sentence += ' ' + sentence[j][ 'sentence' ] 
        sentence[i][ 'combined_sentence' ] = combined_sentence 
    return sentences 
sentence = Combine_sentences(sentences) 
# 生成嵌入
oai_embeds = OpenAIEmbeddings()
embeddings = oai_embeds.embed_documents([x[ 'combined_sentence' ] for x in sentence]) 
# 将嵌入添加到句子
for i, sentence in  enumerate (sentences): 
    sentence[ 'combined_sentence_embedding' ] = embeddings[i] 
# 计算余弦距离
def  calculate_cosine_distances ( sentence ): 
    distances = [] 
    for i in  range ( len (sentences) - 1 ): 
        embedding_current = sentence[i][ 'combined_sentence_embedding' ] 
        embedding_next = sentence[i + 1 ][ 'combined_sentence_embedding' ] 
        similarity = cosine_similarity([embedding_current], [embedding_next])[ 0 ][ 0 ] 
        distance = 1 - similarity 
        distances.append(distance) 
        sentences[i][ 'distance_to_next' ] = distance 
    return distances, sentence 
distances, sentences = calculate_cosine_distances(sentences) 
# 确定断点并创建块
import numpy as np 
breakpoint_distance_threshold = np.percentile(distances, 95 ) 
indices_above_thresh = [i for i, x in  enumerate (distances) if x > breakpoint_distance_threshold] 
# 将句子合并成块
chunks = [] 
start_index = 0 
for index in indices_above_thresh: 
    end_index = index 
    group = sentences[start_index:end_index + 1 ] 
    combined_text = ' ' .join([d[ 'sentence' ] for d in group]) 
    chunks.append(combined_text) 
    start_index = index + 1 
if start_index < len (sentences): 
    combined_text = ' ' .join([d[ 'sentence' ] for d in sentence[start_index:]]) 
    chunks.append(combined_text) 
# 显示创建的块
for i, chunk in  enumerate (chunks): 
    print (f"块 # {i+ 1 } :\n {块} \n" )

语义分割使用嵌入来创建语义相似的块,从而提高 RAG 系统中的检索准确性和上下文生成。关注文本的含义可确保每个块都包含连贯且相关的信息,从而提高 RAG 应用程序的性能和可靠性。

主体分裂

代理拆分利用大型语言模型的强大功能,根据对文本的语义理解动态地创建块。

这种先进的方法通过评估内容和上下文来确定最佳块边界,从而模仿人类的分块方法。

Agentic Splitter 不依赖预定义规则或纯统计方法,而是通过动态评估内容来处理文本,类似于人们阅读文档并根据思路和句子上下文决定在哪里拆分文档的方式。这种方法增强了生成的块的连贯性和相关性。

施事组块

优点:

  • 高精度: 通过使用复杂的语言模型,提供高度相关且上下文准确的块。
  • 适应性: 可以处理多种类型的文本并动态调整分块策略。

缺点:

  • 资源密集型和额外的 LLM 成本: 需要大量计算资源来运行大型语言模型。
  • 复杂的实施: 涉及设置和微调语言模型以获得最佳性能。

如何在 LangGraph 中实现代理拆分器

了解 LangGraph 中的节点:  LangGraph 中的节点表示工作流中的操作或步骤。每个节点接受输入、处理输入并生成输出,该输出将传递到下一个节点。

从langgraph.nodes导入InputNode、SentenceSplitterNode、LLMDecisionNode、ChunkingNode 

# 步骤 1:输入节点
input_node = InputNode(name= "文档输入" ) 

# 步骤 2:句子拆分节点
splitter_node = SentenceSplitterNode( input =input_node.output, name= "句子拆分器" ) 

# 步骤 3:LLM 决策节点
decision_node = LLMDecisionNode( 
    input =splitter_node.output, 
    prompt_template= "句子 '{next_sentence}' 是否与 '{current_chunk}' 属于同一块?" , 
    name= "LLM 决策"
 ) 

# 步骤 4:分块节点
chunking_node = ChunkingNode( input =decision_node.output, name= "语义分块" ) 

# 运行图表
document = "您的文档文本在这里..."
 result = chunking_node.run(document=document) 
print (result)

结论

总之,分块是优化检索增强生成 (RAG) 系统的重要策略,可以实现更准确、上下文相关和可扩展的响应。

通过将大文本分解为可管理的部分,我们可以提高检索准确性并提高人工智能应用程序的整体效率。

采用先进的分块技术对于人工智能驱动的解决方案的持续成功和进步至关重要。