为了账号安全,请及时绑定邮箱和手机立即绑定

RAG 101:分段策略

释放 RAG 工作流的全部潜能 为什么、什么时候和怎样为增强型RAG进行分段处理

我们怎么分这些球?

大型语言模型在单个请求中可以处理的最大令牌数称为上下文窗口(或上下文窗口)。下表显示了 GPT-4(截至2024年9月)所有版本的上下文窗口[[GPT-4各版本的上下文长度](https://platform.openai.com/docs/models/gpt-4-turbo-and-gpt-4)]。尽管每次迭代和每种新模型都会增加上下文窗口,但我们提供给模型的信息仍然存在限制。此外,输入大小与生成响应的相关性之间存在反比关系,简短且专注的输入比包含大量信息的长上下文更能获得更好的结果。这表明我们需要将数据拆分成较小且相关的片段,以便获得更合适的回应——至少在大型语言模型能够处理大量数据而无需重新训练的情况下之前

gpt-4模型的上下文窗口限制(参考自OpenAI的官方文档)

上下文窗口同时包含了输入和输出令牌。

为什么上下文的长度很重要呢

尽管较长的上下文能够给模型一个更全面的视角,帮助模型理解关系并做出更好的推断,但另一方面,较短的上下文减少了模型需要处理的数据量,从而降低延迟,使得模型更加响应迅速。由于只提供了相关数据,因此有助于最小化大语言模型的幻觉。所以我们需要在性能、效率和数据复杂性之间找到一个平衡点,并通过实验确定在合理资源范围内,使用多少数据量能取得最佳结果。

GPT-4模型的128k令牌(token)可能看起来很多,让我们将其转换为实际单词并放到实际情境中理解。根据OpenAI 分词器的信息:

一个实用的经验法则是,一般一个token对应大约4个英文文本中的字符。这相当于大约3/4个英文单词,所以100个token大约相当于75个英文单词。

我们以阿瑟·柯南·道尔的《巴斯克维尔的猎犬》(https://www.gutenberg.org/ebooks/2852)作为例子。这本书共有7734行,62303个单词,大约包含83,700个词元

如果你对不仅仅估算,而是精确地计算token感兴趣的话,你可以使用OpenAI的tiktoken

    导入 requests。  
    从 tiktoken 导入 encoding_for_model:  

    url = "https://www.gutenberg.org/cache/epub/3070/pg3070.txt"  

    response = requests.get(url)  
    若 response.status_code == 200:  
        book_full_text = response.text  

    encoder = encoding_for_model("gpt-4o")  
    tokens = encoder.encode(book_full_text)  

    print(f"token 数量: {len(tokens)}")

这个文本的标记数量是82069。

什么是分块(Chunking)

切奶酪!!(用Canva生成的)

我喜欢维基定义中的“chunking”定义,因为它不仅适用于RAG(检索增援生成),也同样适用于认知心理学中的情况。

分段整理是一种将信息中的小部分绑定在一起的过程。这种分段整理旨在提高短期记忆,从而绕过工作记忆的限制,让工作记忆更有效。

分块是指将大型数据集拆分成更小且更有意义的信息片段,以便更有效地利用LLM的非参数记忆。有许多不同的方法,根据数据类型选择合适的方法来拆分数据,以提高RAG中片段检索的效果。

Chunking 是 RAG 流水线中的一个关键预检索步骤,它直接影响检索过程。它显著影响最终结果。本文将探讨最常见的 chunking 策略,并在我们数据背景下评估这些策略的检索性能。

对分割器的感性认识

与其立刻回顾各种库中存在的分块策略/拆分器,不如先从一个基本拆分器开始,以此来探索编写新拆分器时需要考虑的一些重要方面,从而建立起直觉。让我们逐步改进它,解决它的缺点/限制。

1. 简单分块

谈到分割数据时,我们首先想到的是在换行符处分割。让我们继续来实现。但是,正如您所见,它会留下许多回车符。而且,我们只是假设了\n\r,因为我们只处理的是英语文本,但如果还要解析其他语言呢?让我们增加传递分割符的灵活性。

def naive_splitter_v2(text: str, separators: List[str] = ["\n", "\r"]) -> List[str]:  
    """在每个分隔符处分割文本"""  
    splits = [text]  
    for sep in separators:  
        splits = [segment for part in splits for segment in part.split(sep) if segment]  

    return splits

naive_splitter_v2输出的内容

你或许已经从输出中猜到了我们为什么称这种方法为“朴素”的。这种方法存在不少缺点:

  1. 没有固定的块大小限制。只要一行包含一个分隔符,就会被断开;但如果某个块没有那些分隔符,它的长度可以任意长。
  2. 同样,如输出中清楚可见,有些块太小了!单独的单词块在没有上下文的情况下就失去了意义。
  3. 例如,行中的断开:块根据问题获取,但如果在句子中途截断,句子/行会完全不完整甚至可能改变意思。

咱们一个一个来解决这些问题吧。

2.\ 固定窗口切分

让我们先来应对第一个问题,即段落长度不合适的问题。这次我们先设定一个大小限制,在达到限制时准确分割文本。

    def fixed_window_splitter(text: str, chunk_size: int = 1000) -> List[str]:  
        """按给定的chunk_size(分块大小)分割文本"""  
        # 将输入的文本按照指定的chunk_size分割成多个字符串列表。
        splits = []  
        for i in range(0, len(text), chunk_size):  
            splits.append(text[i:i + chunk_size])  
        return splits

固定窗口分割器的输出结果

我们解决了块的最小和最大边界的问题,因为它总是保持为chunk_size大小。但是单词间的断开依然存在。从输出可以看出,由于句子在中间被分割,我们失去了块原有的意义。

3. 固定窗口和重叠分块。

最简单的方法是确保我们不会在单词中间分割,就是一直读到单词的末尾再停下来。这样做虽然能使上下文不至于太长,并保持在预期的chunk大小范围内,但更好的方法是从实际开始位置后面某个位置x __个字符/单词/标记开始下一个片段,这样可以确保上下文始终连续。

    def fixed_window_with_overlap_splitter(text: str, chunk_size: int = 1000, chunk_overlap: int = 10) -> List[str]:  
        """按照给定的块大小来分割文本,从上一个块的起始位置减去块重叠的部分来开始新的块"""  
        chunks = []  
        start = 0  

        while start <= len(text):  
            end = start + chunk_size  
            chunks.append(text[start:end])  
            start = end - chunk_overlap  

        return chunks

带有重叠的固定窗口分割器的输出

4. 递归字符分割

Chunk SizeChunk Overlap 固定时,我们现在可以解决中间被截断的单词或句子的问题。这可以通过对我们最初的Naive分割器进行一些修改来实现。当我们接近块大小时,我们使用一个分隔符列表,并选择一个合适的分隔符。同时,我们会继续以相同的方式使用重叠部分。这是LangChain包中最受欢迎的分割器之一,名为RecursiveCharacterTextSplitter。它的工作方式与我们前面提到的方法一样。

  1. 从优先级最高的分隔符开始,从 \n\n 开始,并移动到下一个 separators 列表中的分隔符。
  2. 如果分割后的长度超过了 chunk_size,它将依次尝试使用下一个分隔符,直到分割后的长度符合 chunk_size 要求为止。
  3. 下一个分割从当前分割结束位置后跳过 chunk_overlap 个字符处开始,从而保持上下文的连续性。

递归分隔字符后的结果

第4. 语义分段

到目前为止,我们只考虑了如何分割数据,比如在段落结尾、新的一行、制表符或其他分隔符处。但我们还没有考虑什么时候进行分割,即如何更好地捕捉有意义的片段,而不仅仅是某个长度的片段。这种方法叫做语义分块。接下来,我们使用Flair来检测句子边界或特定实体,生成有意义的片段。文本使用_SegtokSentenceSplitter_分割成句子,以确保划分在有意义的边界进行。我们保持相同的分块逻辑:分组直到达到_chunk_size_的大小,并保持_chunk_overlap_的重叠,以确保上下文得以保留。

    def 语义分割(text: str, chunk_size: int = 1000, chunk_overlap: int = 10) -> List[str]:  
        from flair.models import SequenceTagger  
        from flair.data import Sentence  
        from flair.splitter import SegtokSentenceSplitter  

        分割器 = SegtokSentenceSplitter()  

        # 将文本分割成句子列表  
        句子列表 = 分割器.split(text)  

        片段 = []  
        当前片段 = ""  

        for sentence in 句子列表:  
            # 添加句子到当前片段  
            if len(当前片段) + len(sentence.to_plain_string()) <= chunk_size:  
                当前片段 += " " + sentence.to_plain_string()  
            else:  
                # 如果添加下一个句子会超过最大长度,则开始一个新的片段  
                片段.append(当前片段.strip())  
                当前片段 = sentence.to_plain_string()  

        # 如果有当前片段,则添加  
        if 当前片段:  
            片段.append(当前片段.strip())  

        return 片段

语义分割器的输出结果

LangChain 有两个这样的切分器,分别使用NLTKspaCy 库,所以你可以看看这两个库,了解更多详情。

所以,通常,在静态分块方法中,制定分块策略时主要考虑两个因素:分块大小分块重叠。分块大小是指每个分块包含的字符、单词或令牌的数量,而分块重叠是指当前分块中包含前一个分块的部分,以保持上下文连贯。分块重叠也可以用字符/单词/令牌的数量或分块大小的百分比来表示。

你可以试试使用很酷的ChunkViz工具来查看不同分块策略在不同块大小和重叠参数下如何表现。

你可以在这里找到《巴斯克维尔家族的猎犬》的相关信息 ChunkViz

5. 嵌入式分段

尽管语义分块可以完成任务,NLTK、spaCy 或 Flair 会使用它们各自的模型/嵌入来理解给定的数据,并尝试以最佳方式对数据进行语义分割。当我们转向实际的RAG实现时,我们的嵌入可能与我们用来合并分块的嵌入不同,因此可能以完全不同的方式被理解。因此,这种方法从句子级别的分块开始,并基于我们后续用于RAG检索的相同嵌入模型来划分和组合这些分块。为了有所不同,我们使用NLTK进行句子分割,并使用OpenAIEmbeddings来合并这些句子。

    def embedding_splitter(text_data, chunk_size=400):  
        import os  
        import nltk  
        from langchain_openai.embeddings import AzureOpenAIEmbeddings  
        from sklearn.metrics.pairwise import cosine_similarity  
        import numpy as np  
        from dotenv import load_dotenv, find_dotenv  
        from tqdm import tqdm  
        from flair.splitter import SegtokSentenceSplitter  

        load_dotenv(find_dotenv())  

        # 设置Azure OpenAI API环境变量(请确保您已经在环境中设置了这些变量)  
        # 您也可以直接在您的环境中设置这些变量  
        # os.environ["OPENAI_API_KEY"] = "your-azure-openai-api-key"  
        # os.environ["OPENAI_API_BASE"] = "your-azure-openai-api-endpoint"  
        os.environ["OPENAI_API_VERSION"] = "2023-05-15"  

        # 使用您的Azure模型名称(请替换为实际模型名称)初始化OpenAIEmbeddings  
        embedding_model = AzureOpenAIEmbeddings(deployment="text-embedding-ada-002-01")  # 使用您的Azure模型名称  

        # 步骤1:将文本拆分成句子  
        def split_into_sentences(text):  
            splitter = SegtokSentenceSplitter()  

            # 将文本拆分成句子  
            sentences = splitter.split(text)  
            sentence_str = []  
            for sentence in sentences:  
                sentence_str.append(sentence.to_plain_string())  
            return sentence_str[:100]  

        # 步骤2:使用相同的Azure嵌入模型为每个句子获取嵌入  
        def get_embeddings(sentences):  
            embeddings = []  
            for sentence in tqdm(sentences, desc="Generating embeddings"):  
                embedding = embedding_model.embed_documents([sentence])  # 嵌入单一句子  
                embeddings.append(embedding[0])  # embed_documents返回一个列表,取第一个元素  
            return embeddings  

        # 步骤3:根据句子嵌入、相似度阈值和最大块字符大小形成块  
        def form_chunks(sentences, embeddings, similarity_threshold=0.7, chunk_size=500):  
            chunks = []  
            current_chunk = []  
            current_chunk_emb = []  
            current_chunk_length = 0  # 跟踪当前块的长度  

            for i, (sentence, emb) in enumerate(zip(sentences, embeddings)):  
                emb = np.array(emb)  # 确保嵌入是numpy数组  
                sentence_length = len(sentence)  # 计算句子的长度  

                if current_chunk:  
                    # 计算与当前块的嵌入(块中嵌入的平均值)的相似度  
                    chunk_emb = np.mean(np.array(current_chunk_emb), axis=0).reshape(1, -1)  # 块中嵌入的平均值  
                    similarity = cosine_similarity(emb.reshape(1, -1), chunk_emb)[0][0]  

                    if similarity < similarity_threshold or current_chunk_length + sentence_length > chunk_size:  
                        # 如果相似度低于阈值或添加此句子超过最大块大小,则创建新块  
                        chunks.append(current_chunk)  
                        current_chunk = [sentence]  
                        current_chunk_emb = [emb]  
                        current_chunk_length = sentence_length  # 重置块长度  
                    else:  
                        # 否则,将句子添加到当前块  
                        current_chunk.append(sentence)  
                        current_chunk_emb.append(emb)  
                        current_chunk_length += sentence_length  # 更新当前块的长度  
                else:  
                    current_chunk.append(sentence)  
                    current_chunk_emb = [emb]  
                    current_chunk_length = sentence_length  # 设置初始块长度  

            # 添加当前块  
            if current_chunk:  
                chunks.append(current_chunk)  

            return chunks  

        # 应用句子拆分  
        sentences = split_into_sentences(text_data)  

        # 获取句子嵌入  
        embeddings = get_embeddings(sentences)  

        # 基于嵌入形成块  
        chunks = form_chunks(sentences, embeddings, chunk_size=chunk_size)  

        return chunks

embedding_splitter 的输出结果

6. 代理分组

我们的嵌入分块应在分隔数据时更接近于通过计算嵌入的余弦相似度。虽然这种方法效果不错,但有一个主要缺点:它不能理解文本的意思。“我喜欢你”和“我 喜欢 你”(带有讽刺意味的“喜欢”),这两句话的嵌入将相同,因此它们的余弦相似度也将相同。这时候,代理(或基于LLM的)分块就显得非常有用。它会分析内容,并根据独立性和语义连贯性来确定逻辑分割点。

    def agentic_chunking(text_data):  
        from langchain_openai import AzureChatOpenAI  
        from langchain.prompts import PromptTemplate  
        from langchain  
        llm = AzureChatOpenAI(model="gpt-4o",  
                               api_version="2023-03-15-preview",  
                               verbose=True,  
                               temperature=1)  
        prompt = """我将提供一个文档。  
        请将文档分成保持语义连贯的块,确保每个块都代表一个完整且有意义的信息单元。  
        每个块应该独立存在,保留上下文和意义,不要将关键概念分割在不同的块之间。  
        使用你对内容结构、主题和连贯性的理解来识别文本中的自然断点。  
        确保每个块不超过1000个字符长度,并优先将相关概念或部分保持在一起。  

        请不要修改文档,只需将其分割成块,并返回这些块作为字符串数组,每个字符串代表文档的一个块。  
        请返回整个文档,不要在某些句子之间中断。  

        文档:  
        {document}  
        """  

        prompt_template = PromptTemplate.from_template(prompt)  

        chain = prompt_template | llm  

        result = chain.invoke({"document": text_data})  
        return result
评估

我们将在这即将发布的一篇文章中介绍RAG评估技术;在这篇文章中,我们将介绍由RAGAS定义的两个指标,即context_precisioncontext_relevance,这两个指标可以衡量我们的分段策略表现如何。

上下文精准度 是一个评估指标,用于判断所有在上下文中出现的地面真实相关项目是否都被排在了较高的位置。理想情况下,所有相关的片段都应该出现在较高的排名位置。该指标通过问题、实际相关项和上下文进行计算,取值范围在0到1之间,得分越高表示精度越准确。

上下文相关性 用于衡量检索出的上下文的相关性,根据问题和上下文共同计算得出。取值范围在0到1之间,得分越高表示相关性越强。

在接下来的文章中,我们将讨论提案检索技术,这是代理分割的一种方法,并为所有策略计算所有策略的RAGAS指标。我们对所有策略进行计算,以确保更流畅和明确的表达。

结论:

在这篇文章中,我们探讨了为什么我们需要分块,并培养了一些关于策略的直觉,包括它们的实现方式以及在一些知名库中的相应代码。这些只是基本的分块策略,尽管每天都有越来越多的新策略被发明出来,以进一步改善检索,以使检索更加高效。

🚀 探索代码 GitHub 上,并用一个 ⭐ 来点赞支持,如果你觉得它有用! 🙌✨

除非特别注明,所有图片均为作者拍摄。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消