🧠 向所有学习者致敬!

“学习不是装满一桶水,而是点燃一把火。” —— 叶芝


我的博客主页: https://lizheng.blog.csdn.net

🌐 欢迎点击加入AI人工智能社区

🚀 让我们一起努力,共创AI未来! 🚀


如果你还不知道RAG是什么,那你就out啦!简单来说,RAG是一种结合了检索和生成的模型,能够从大量文档中提取信息并生成高质量的答案。而RAG(Retrieval-Augmented Generation)更优雅的技术——语义分块,就是让这个过程更加高效和精准的关键技术。

什么是语义分块?

在RAG中,我们首先需要将文档解析成结构化的数据,然后将其拆分成更小的块,以便提取详细的特征并进行嵌入表示。传统的分块方法通常是基于规则的,比如固定大小的分块或相邻分块的重叠。然而,这些方法在实际应用中可能会遇到一些问题,比如检索上下文不完整或分块过大导致噪声过多。

语义分块的目标是确保每个分块尽可能包含语义上独立的信息。这样一来,RAG模型在检索和生成时就能更加精准地找到相关信息,避免“捡了芝麻丢了西瓜”的情况。
语义分块就像是给RAG模型装上了一副“智能眼镜”,让它能够更精准地找到需要的信息。

语义分块的三种方法

在这里插入图片描述

接下来,我们逐一来看这些方法的原理和应用。

基于嵌入的方法

LlamaIndex提供了基于嵌入的语义分块器。算法的思想大致相同。

pip install llama-index-core

pip install llama-index-readers-file

pip install llama-index-embeddings-openai

pip install httpx[socks]

测试代码如下:

from llama_index.core.node_parser import (
    SentenceSplitter,
    SemanticSplitterNodeParser,
)
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.core import SimpleDirectoryReader


import os
os.environ["OPENAI_API_KEY"] = "YOUR_OPEN_AI_KEY"

# 加载文档
dir_path = "YOUR_DIR_PATH"
documents = SimpleDirectoryReader(dir_path).load_data()


embed_model = OpenAIEmbedding()
splitter = SemanticSplitterNodeParser(
    buffer_size=1, breakpoint_percentile_threshold=95, embed_model=embed_model
)

nodes = splitter.get_nodes_from_documents(documents)
for node in nodes:
    print('-' * 100)
    print(node.get_content())

我追踪了**splitter.get_nodes_from_documents**函数,其主要过程如图2所示:

None

图2:splitter.get_nodes_from_documents 函数的主要过程。作者提供的图片。

图2中提到的“sentences”是一个Python列表,每个成员都是一个包含四个(键,值)对的字典,键的含义如下:

  • sentence:当前句子
  • index:当前句子的序号
  • combined_sentence:一个滑动窗口,包括**[index -self.buffer_size, index, index + self.buffer_size]** 3个句子(默认情况下,self.buffer_size = 1)。它是用于计算句子之间语义相关性的工具。结合前后句子的目的是减少噪声并更好地捕捉顺序句子之间的关系。
  • combined_sentence_embedding:combined_sentence的嵌入

从上述分析可以看出,基于嵌入的语义分块本质上是通过滑动窗口(combined_sentence)计算相似性。那些相邻且满足阈值的句子被分类到一个块中。

目录路径仅包含一个BERT论文文档。以下是一些运行结果:

python test_semantic_chunk.py 
...
...
----------------------------------------------------------------------------------------------------
We argue that current techniques restrict the
power of the pre-trained representations, espe-
cially for the fine-tuning approaches. The ma-
jor limitation is that standard language models are
unidirectional, and this limits the choice of archi-
tectures that can be used during pre-training. For
example, in OpenAI GPT, the authors use a left-to-
right architecture, where every token can only at-
tend to previous tokens in the self-attention layers
of the Transformer (Vaswani et al., 2017). Such re-
strictions are sub-optimal for sentence-level tasks,
and could be very harmful when applying fine-
tuning based approaches to token-level tasks such
as question answering, where it is crucial to incor-
porate context from both directions.
In this paper, we improve the fine-tuning based
approaches by proposing BERT: Bidirectional
Encoder Representations from Transformers.
BERT alleviates the previously mentioned unidi-
rectionality constraint by using a "masked lan-
guage model" (MLM) pre-training objective, in-
spired by the Cloze task (Taylor, 1953). The
masked language model randomly masks some of
the tokens from the input, and the objective is to
predict the original vocabulary id of the maskedarXiv:1810.04805v2  [cs.CL]  24 May 2019
----------------------------------------------------------------------------------------------------
word based only on its context. Unlike left-to-
right language model pre-training, the MLM ob-
jective enables the representation to fuse the left
and the right context, which allows us to pre-
train a deep bidirectional Transformer. In addi-
tion to the masked language model, we also use
a "next sentence prediction" task that jointly pre-
trains text-pair representations. The contributions
of our paper are as follows:
• We demonstrate the importance of bidirectional
pre-training for language representations. Un-
like Radford et al. (2018), which uses unidirec-
tional language models for pre-training, BERT
uses masked language models to enable pre-
trained deep bidirectional representations. This
is also in contrast to Peters et al. 
----------------------------------------------------------------------------------------------------
...
...

总结

测试结果表明,块的粒度相对较粗。

图2还显示,该方法是基于页面的,并没有直接解决跨页面的块问题。

一般来说,基于嵌入的方法的性能严重依赖于嵌入模型。实际效果需要未来的评估。

基于模型的方法

朴素BERT

回顾BERT的预训练过程。设计了一个二元分类任务,即下一句预测(NSP),以教模型两个句子之间的关系。这里,两个句子同时输入到BERT中,模型预测第二个句子是否跟随第一个句子。

我们可以应用这一原理设计一个简单的分块方法。对于文档,将其拆分为句子。然后,使用滑动窗口将两个相邻的句子输入到BERT模型中进行NSP判断,如图3所示:

None

图3:使用BERT进行分块。作者提供的图片。

如果预测分数低于预设阈值,则表明两个句子之间的语义关系较弱。这可以作为文本分割点,如图3中句子2和句子3之间所示。

这种方法的优点是无需训练或微调即可直接使用。

然而,这种方法在确定文本分割点时只考虑了前后句子,忽略了更远部分的信息。此外,该方法的预测效率相对较低。

跨段注意力

论文Text Segmentation by Cross Segment Attention提出了三种关于跨段注意力的模型,如图4所示:

None

图4:在跨段BERT模型(左)中,我们向模型输入围绕潜在段断点的局部上下文:左侧的k个标记和右侧的k个标记。在BERT+Bi-LSTM模型(中)中,我们首先使用BERT模型对每个句子进行编码,然后将句子表示输入到Bi-LSTM中。在分层BERT模型(右)中,我们首先使用BERT对每个句子进行编码,然后将输出句子表示输入到另一个基于Transformer的模型中。来源:Text Segmentation by Cross Segment Attention

图4(a)显示了跨段BERT模型,它将文本分割定义为逐句分类任务。潜在断点的上下文(两侧的**k个标记)被输入到模型中。与[CLS]**对应的隐藏状态被传递给softmax分类器,以决定是否在潜在断点处进行分割。

论文还提出了另外两个模型。一个使用BERT模型获取每个句子的向量表示。然后将多个连续句子的向量表示输入到Bi-LSTM(图4(b))或另一个BERT(图4(c))中,以预测每个句子是否为文本分割边界。

当时,这三种模型取得了最先进的结果,如图5所示:

None

图5:文本分割和话语分割的测试集结果。来源:Text Segmentation by Cross Segment Attention

然而,到目前为止,只发现了该论文的训练实现。尚未发现公开的推理模型。

SeqModel

跨段模型独立地对每个句子进行向量化,没有考虑任何更广泛的上下文信息。SeqModel中提出了进一步的改进,详见论文"Sequence Model with Self-Adaptive Sliding Window for Efficient Spoken Document Segmentation"。

SeqModel使用BERT同时编码多个句子,在计算句子向量之前对较长上下文中的依赖关系进行建模。然后预测每个句子后是否进行文本分割。此外,该模型利用自适应滑动窗口方法在不影响准确性的情况下提高推理速度。SeqModel的示意图如图6所示。

None

图6:提出的SeqModel架构和自适应滑动窗口推理方法。来源:Sequence Model with Self-Adaptive Sliding Window for Efficient Spoken Document Segmentation

SeqModel可以通过ModelScope框架使用。代码如下:

from modelscope.outputs import OutputKeys
from modelscope.pipelines import pipeline
from modelscope.utils.constant import Tasks

p = pipeline(
    task = Tasks.document_segmentation,
    model = 'damo/nlp_bert_document-segmentation_english-base'
)

print('-' * 100)

result = p(documents='We demonstrate the importance of bidirectional pre-training for language representations. Unlike Radford et al. (2018), which uses unidirectional language models for pre-training, BERT uses masked language models to enable pretrained deep bidirectional representations. This is also in contrast to Peters et al. (2018a), which uses a shallow concatenation of independently trained left-to-right and right-to-left LMs. • We show that pre-trained representations reduce the need for many heavily-engineered taskspecific architectures. BERT is the first finetuning based representation model that achieves state-of-the-art performance on a large suite of sentence-level and token-level tasks, outperforming many task-specific architectures. Today is a good day')

print(result[OutputKeys.TEXT])

测试数据在末尾附加了一个句子Today is a good day,但结果并没有以任何方式分隔Today is a good day

(modelscope) Florian:~ Florian$ python /Users/Florian/Documents/june_pdf_loader/test_seqmodel.py 
2024-02-24 17:09:36,288 - modelscope - INFO - PyTorch version 2.2.1 Found.
2024-02-24 17:09:36,288 - modelscope - INFO - Loading ast index from /Users/Florian/.cache/modelscope/ast_indexer
...
...
----------------------------------------------------------------------------------------------------
...
... 
We demonstrate the importance of bidirectional pre-training for language representations.Unlike Radford et al.(2018), which uses unidirectional language models for pre-training, BERT uses masked language models to enable pretrained deep bidirectional representations.This is also in contrast to Peters et al.(2018a), which uses a shallow concatenation of independently trained left-to-right and right-to-left LMs.• We show that pre-trained representations reduce the need for many heavily-engineered taskspecific architectures.BERT is the first finetuning based representation model that achieves state-of-the-art performance on a large suite of sentence-level and token-level tasks, outperforming many task-specific architectures.Today is a good day

基于模型的方法:总结

总体而言,基于模型的语义分块方法仍有很大的改进空间。

我建议的一种改进方法是创建特定项目的训练数据以进行领域微调。这可以提高模型的性能。此外,优化模型架构也是一个改进点。

如果我们能找到在特定业务数据上表现良好的模型,基于模型的方法仍然有效。

基于LLM的方法

论文Dense X Retrieval: What Retrieval Granularity Should We Use?引入了一种新的检索单元,称为命题。命题被定义为文本中的原子表达式,每个命题都封装了一个独特的事实,并以简洁、自包含的自然语言格式呈现。

那么,我们如何获得这些所谓的命题呢?在论文中,这是通过构建提示并与LLM交互来实现的。

LlamaIndexLangchain都实现了相关算法,以下演示使用LlamaIndex。

LlamaIndex的实现思路是使用论文中提供的提示生成命题:

PROPOSITIONS_PROMPT = PromptTemplate(
    """将“内容”分解为清晰简单的命题,确保它们在上下文之外可解释。
1. 将复合句拆分为简单句。尽可能保留输入中的原始措辞。
2. 对于任何带有附加描述信息的命名实体,将此信息分离为独立的命题。
3. 通过添加必要的修饰语到名词或整个句子,并替换代词(例如“它”、“他”、“她”、“他们”、“这个”、“那个”)为它们所指实体的全名,来去上下文化命题。
4. 将结果呈现为字符串列表,格式化为JSON。

输入:标题:¯Eostre。章节:理论与解释,与复活节兔子的联系。内容:
关于复活节兔子(Osterhase)的最早证据是由医学教授Georg Franck von Franckenau于1678年在德国西南部记录的,但在18世纪之前,德国其他地区对此一无所知。学者Richard Sermon写道:“春天经常在花园里看到兔子,因此它们可能为隐藏在那里的彩蛋的起源提供了一个方便的解释。或者,有一个欧洲传统认为兔子会下蛋,因为兔子的抓痕或巢穴与田凫的巢穴非常相似,两者都出现在草地上,并在春天首次出现。在19世纪,复活节卡片、玩具和书籍的影响使复活节兔子/兔子在整个欧洲流行起来。德国移民随后将这一习俗出口到英国和美国,在那里它演变成了复活节兔子。”
输出:[ "关于复活节兔子的最早证据是由Georg Franck von Franckenau于1678年在德国西南部记录的。", "Georg Franck von Franckenau是一位医学教授。", "关于复活节兔子的证据在18世纪之前,德国其他地区对此一无所知。", "Richard Sermon是一位学者。", "Richard Sermon写了一个关于复活节期间兔子与传统之间联系的可能解释的假设", "春天经常在花园里看到兔子。", "兔子可能为隐藏在那里的彩蛋的起源提供了一个方便的解释。", "有一个欧洲传统认为兔子会下蛋。", "兔子的抓痕或巢穴与田凫的巢穴非常相似。", "兔子和田凫的巢穴都出现在草地上,并在春天首次出现。", "在19世纪,复活节卡片、玩具和书籍的影响使复活节兔子/兔子在整个欧洲流行起来。", "德国移民将复活节兔子/兔子的习俗出口到英国和美国。", "复活节兔子/兔子的习俗在英国和美国演变成了复活节兔子。" ]

输入:{node_text}
输出:"""
)

在上一节基于嵌入的方法中,我们已经安装了LlamaIndex 0.10.12的关键组件。但如果我们想使用DenseXRetrievalPack,还需要运行pip install llama-index-llms-openai。安装后,当前的LlamaIndex相关组件如下:

(llamaindex_010) Florian:~ Florian$ pip list | grep llama
llama-index-core                    0.10.12
llama-index-embeddings-openai       0.1.6
llama-index-llms-openai             0.1.6
llama-index-readers-file            0.1.5
llamaindex-py-client                0.1.13

在LlamaIndex中,**DenseXRetrievalPack**是一个需要单独下载的包。这里直接在测试代码中下载。测试代码如下:

from llama_index.core.readers import SimpleDirectoryReader
from llama_index.core.llama_pack import download_llama_pack

import os
os.environ["OPENAI_API_KEY"] = "YOUR_OPENAI_KEY"

# 下载并安装依赖
DenseXRetrievalPack = download_llama_pack(
    "DenseXRetrievalPack", "./dense_pack"
)

# 如果您已经下载了DenseXRetrievalPack,可以直接导入。
# from llama_index.packs.dense_x_retrieval import DenseXRetrievalPack

# 加载文档
dir_path = "YOUR_DIR_PATH"
documents = SimpleDirectoryReader(dir_path).load_data()


# 使用LLM从每个文档/节点中提取命题
dense_pack = DenseXRetrievalPack(documents)

response = dense_pack.run("YOUR_QUERY")

通过测试代码可以发现,**class DenseXRetrievalPack**的构造函数主要在使用。分析源代码 class DenseXRetrievalPack 是必要的。

class DenseXRetrievalPack(BaseLlamaPack):
    def __init__(
        self,
        documents: List[Document],
        proposition_llm: Optional[LLM] = None,
        query_llm: Optional[LLM] = None,
        embed_model: Optional[BaseEmbedding] = None,
        text_splitter: TextSplitter = SentenceSplitter(),
        similarity_top_k: int = 4,
    ) -> None:
        """初始化参数。"""
        self._proposition_llm = proposition_llm or OpenAI(
            model="gpt-3.5-turbo",
            temperature=0.1,
            max_tokens=750,
        )

        embed_model = embed_model or OpenAIEmbedding(embed_batch_size=128)

        nodes = text_splitter.get_nodes_from_documents(documents)
        sub_nodes = self._gen_propositions(nodes)

        all_nodes = nodes + sub_nodes
        all_nodes_dict = {n.node_id: n for n in all_nodes}

        service_context = ServiceContext.from_defaults(
            llm=query_llm or OpenAI(),
            embed_model=embed_model,
            num_output=self._proposition_llm.metadata.num_output,
        )

        self.vector_index = VectorStoreIndex(
            all_nodes, service_context=service_context, show_progress=True
        )

        self.retriever = RecursiveRetriever(
            "vector",
            retriever_dict={
                "vector": self.vector_index.as_retriever(
                    similarity_top_k=similarity_top_k
                )
            },
            node_dict=all_nodes_dict,
        )

        self.query_engine = RetrieverQueryEngine.from_args(
            self.retriever, service_context=service_context
        )

如代码所示,构造函数的思路是首先使用**text_splitter将文档划分为原始nodes,然后调用self._gen_propositions通过生成propositions获得相应的sub_nodes。然后使用nodes + sub_nodes构建VectorStoreIndex,可以通过RecursiveRetriever**进行检索。递归检索器可以使用小块进行检索,但它将相关的大块传递给生成阶段。

目录路径仅包含一个BERT论文文档。通过调试,我们发现**sub_nodes[].text**不是原始文本,它们已被重写:

> /Users/Florian/anaconda3/envs/llamaindex_010/lib/python3.11/site-packages/llama_index/packs/dense_x_retrieval/base.py(91)__init__()
     90 
---> 91         all_nodes = nodes + sub_nodes
     92         all_nodes_dict = {n.node_id: n for n in all_nodes}


ipdb> sub_nodes[20]
IndexNode(id_='ecf310c7-76c8-487a-99f3-f78b273e00d9', embedding=None, metadata={}, excluded_embed_metadata_keys=[], excluded_llm_metadata_keys=[], relationships={}, text='我们的论文展示了双向预训练对语言表示的重要性。', start_char_idx=None, end_char_idx=None, text_template='{metadata_str}\n\n{content}', metadata_template='{key}: {value}', metadata_seperator='\n', index_id='8deca706-fe97-412c-a13f-950a19a594d1', obj=None)
ipdb> sub_nodes[21]
IndexNode(id_='4911332e-8e30-47d8-a5bc-ed7cbaa8e042', embedding=None, metadata={}, excluded_embed_metadata_keys=[], excluded_llm_metadata_keys=[], relationships={}, text='Radford et al. (2018) 使用单向语言模型进行预训练。', start_char_idx=None, end_char_idx=None, text_template='{metadata_str}\n\n{content}', metadata_template='{key}: {value}', metadata_seperator='\n', index_id='8deca706-fe97-412c-a13f-950a19a594d1', obj=None)
ipdb> sub_nodes[22]
IndexNode(id_='83aa82f8-384a-4b06-92c8-d6277c4162bf', embedding=None, metadata={}, excluded_embed_metadata_keys=[], excluded_llm_metadata_keys=[], relationships={}, text='BERT使用掩码语言模型来实现预训练的深度双向表示。', start_char_idx=None, end_char_idx=None, text_template='{metadata_str}\n\n{content}', metadata_template='{key}: {value}', metadata_seperator='\n', index_id='8deca706-fe97-412c-a13f-950a19a594d1', obj=None)
ipdb> sub_nodes[23]
IndexNode(id_='2ac635c2-ccb0-4e62-88c7-bcbaef3ef38a', embedding=None, metadata={}, excluded_embed_metadata_keys=[], excluded_llm_metadata_keys=[], relationships={}, text='Peters et al. (2018a) 使用独立训练的从左到右和从右到左语言模型的浅层连接。', start_char_idx=None, end_char_idx=None, text_template='{metadata_str}\n\n{content}', metadata_template='{key}: {value}', metadata_seperator='\n', index_id='8deca706-fe97-412c-a13f-950a19a594d1', obj=None)
ipdb> sub_nodes[24]
IndexNode(id_='e37b17cf-30dd-4114-a3c5-9921b8cf0a77', embedding=None, metadata={}, excluded_embed_metadata_keys=[], excluded_llm_metadata_keys=[], relationships={}, text='预训练表示减少了许多需要大量工程的任务特定架构的需求。', start_char_idx=None, end_char_idx=None, text_template='{metadata_str}\n\n{content}', metadata_template='{key}: {value}', metadata_seperator='\n', index_id='8deca706-fe97-412c-a13f-950a19a594d1', obj=None)

**sub_nodesnodes**之间的关系如图7所示,构建了一个从小到大的索引结构。

None

图7:从小到大的索引结构。作者提供的图片。

从小到大的索引结构是通过self._gen_propositions构建的,代码如下:

    async def _aget_proposition(self, node: TextNode) -> List[TextNode]:
        """获取命题。"""
        inital_output = await self._proposition_llm.apredict(
            PROPOSITIONS_PROMPT, node_text=node.text
        )
        outputs = inital_output.split("\n")

        all_propositions = []

        for output in outputs:
            if not output.strip():
                continue
            if not output.strip().endswith("]"):
                if not output.strip().endswith('"') and not output.strip().endswith(
                    ","
                ):
                    output = output + '"'
                output = output + " ]"
            if not output.strip().startswith("["):
                if not output.strip().startswith('"'):
                    output = '"' + output
                output = "[ " + output

            try:
                propositions = json.loads(output)
            except Exception:
                # 回退到yaml
                try:
                    propositions = yaml.safe_load(output)
                except Exception:
                    # 回退到下一个输出
                    continue

            if not isinstance(propositions, list):
                continue

            all_propositions.extend(propositions)

        assert isinstance(all_propositions, list)
        nodes = [TextNode(text=prop) for prop in all_propositions if prop]

        return [IndexNode.from_text_node(n, node.node_id) for n in nodes]

    def _gen_propositions(self, nodes: List[TextNode]) -> List[TextNode]:
        """获取命题。"""
        sub_nodes = asyncio.run(
            run_jobs(
                [self._aget_proposition(node) for node in nodes],
                show_progress=True,
                workers=8,
            )
        )

        # 展平列表
        return [node for sub_node in sub_nodes for node in sub_node]

对于每个原始**node,异步调用self._aget_proposition通过PROPOSITIONS_PROMPT获取LLM的返回inital_output,然后根据inital_output获取命题并构建TextNode。最后,将这些TextNode与原始node关联,即[IndexNode.from_text_node(n, node.node_id) for n in nodes]**

值得一提的是,原始论文使用LLM生成的命题作为训练数据,进一步微调文本生成模型。文本生成模型现已公开。感兴趣的读者可以尝试。

基于LLM的方法:总结

总的来说,这种使用LLM构建命题的分块方法实现了更精细的分块。它与原始节点形成了一个从小到大的索引结构,从而为语义分块提供了一种新颖的思路。

然而,这种方法依赖于LLM,成本相对较高。

如果条件允许,可以持续跟踪和监控基于LLM的方法。

总结:分块的艺术,从“切菜”到“切语义”

嘿,各位算法大厨们!今天我们聊了聊如何把文档这块“大肉”切成更美味的“小块”——也就是语义分块。别小看这个“切菜”的活儿,它可是RAG(检索增强生成)中的“秘密武器”!

1. 基于嵌入的分块:滑动窗口的“魔法”

这个方法就像是用一个滑动窗口在文档上“滑来滑去”,看看哪些句子是“好基友”,然后把它们分到一个块里。不过,这个方法有时候切得有点“粗”,块太大,噪声也多,还得靠嵌入模型的表现来撑场面。

2. 基于模型的分块:BERT的“直觉”

这里我们请来了BERT大佬,让它来判断两个句子是不是“天生一对”。如果BERT觉得它们“不来电”,那就切一刀!不过,BERT有时候也会“看走眼”,毕竟它只看前后两句,忽略了更远的“朋友圈”。

3. 基于LLM的分块:命题的“精细刀工”

最后,我们请来了LLM(大语言模型)这位“米其林大厨”,它能把文档切成一个个“原子级”的命题,每个命题都自成一派,简洁明了。不过,这位大厨的“出场费”可不低,成本有点高,但效果确实杠杠的!
另外还有:

总结一下:

  • 基于嵌入的分块:简单粗暴,但有时候切得太“糙”。
  • 基于模型的分块:BERT的直觉不错,但有时候也会“看走眼”。
  • 基于LLM的分块:精细如米其林大厨,但成本有点高。

所以,各位大厨们,选择哪种“刀法”取决于你的“食材”和“预算”。不过,无论哪种方法,语义分块都是优化RAG的关键一步。切得好,检索和生成的效果都会更上一层楼!

最后,如果你有任何问题或想法,欢迎在评论区“切”出来!咱们一起讨论,看看怎么把这块“文档肉”切得更香!🍴😄

Logo

GitCode 天启AI是一款由 GitCode 团队打造的智能助手,基于先进的LLM(大语言模型)与多智能体 Agent 技术构建,致力于为用户提供高效、智能、多模态的创作与开发支持。它不仅支持自然语言对话,还具备处理文件、生成 PPT、撰写分析报告、开发 Web 应用等多项能力,真正做到“一句话,让 Al帮你完成复杂任务”。

更多推荐