语义分块的原理与应用 在解析文档 之后,我们可以获得结构化或半结构化数据。当前的主要任务是将它们分解成更小的块以提取详细特征,然后将这些特征嵌入以表示其语义。其在RAG中的位置如图1所示。
最常用的分块方法通常是基于规则的,采用固定块大小或相邻块重叠等技术。对于多级文档,我们可以使用Langchain提供的RecursiveCharacterTextSplitter ,这允许定义多级分隔符。
然而,在实际应用中,由于预定义规则(块大小或重叠部分大小)的刚性,基于规则的分块方法很容易导致检索上下文不完整或包含噪音的块大小过大等问题。
因此,对于分块,最优雅的方法显然是基于语义进行分块 。语义分块旨在确保每个块尽可能包含语义独立的信息。
本文探讨了语义分块的方法,解释了它们的原理和应用。我们将介绍三种类型的方法:
基于嵌入的方法 LlamaIndex 和 Langchain 都提供了基于嵌入的语义分块器。该算法的思路大致相同,我们将以 LlamaIndex 为例进行解释。
请注意,要访问 LlamaIndex 中的语义分块器,您需要安装一个较新的版本。我之前安装的版本 0.9.45 并不包含此算法。因此,我创建了一个新的 conda 环境并安装了更新版本 0.10.12:
1 2 3 4 5 6 7 pip install llama-index-core pip install llama-index-readers-file pip install llama-index-embeddings-openai pip install httpx[socks]
值得一提的是,LlamaIndex 的 0.10.12 版本可以灵活安装,因此这里仅安装了一些关键组件。已安装的版本如下:
1 2 3 4 5 (llamaindex_010) Florian:~ Florian$ pip list | grep llama llama-index-core 0.10 .12 llama-index-embeddings-openai 0.1 .6 llama-index-readers-file 0.1 .5 llamaindex-py-client 0.1 .13
测试代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 from llama_index.core.node_parser import ( SentenceSplitter, SemanticSplitterNodeParser, ) from llama_index.embeddings.openai import OpenAIEmbeddingfrom llama_index.core import SimpleDirectoryReaderimport osos.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())
我追踪了 de>splitter.get_nodes_from_documents 函数,其主要过程如图 2 所示:
图 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 论文 文档。以下是一些运行结果:
1 2 3 4 5 6 7 8 9 10 (llamaindex_010) Florian:~ Florian$ python /Users/Florian/Documents/june_pdf_loader/test_semantic_chunk.py ... ... ---------------------------------------------------------------------------------------------------- 我们认为,当前技术限制了预训练表示的能力,尤其是对于微调方法。主要限制是标准语言模型是单向的,这限制了预训练期间可以使用的架构选择。例如,在 OpenAI GPT 中,作者使用了一种从左到右的架构,其中每个标记只能在 Transformer 的自注意力层中关注之前的标记(Vaswani 等人,2017 )。这种限制对于句子级任务来说是次优的,并且在应用于如问答这样的标记级任务时可能非常有害,因为在这些任务中,双向结合上下文至关重要。 在本文中,我们通过提出 BERT:来自 Transformer 的双向编码器表示,改进了基于微调的方法。BERT 通过使用“掩码语言模型”(MLM)预训练目标,缓解了先前提到的单向性约束,该目标受到 Cloze 任务(Taylor,1953 )的启发。掩码语言模型随机遮蔽输入中的一些标记,目标是仅根据上下文预测原始词汇 ID。与从左到右的语言模型预训练不同,MLM 目标使表示能够融合左右上下文,这使我们能够预训练一个深层双向 Transformer。除了掩码语言模型外,我们还使用了一个“下一句预测”任务,该任务联合预训练文本对表示。我们的论文贡献如下: • 我们展示了双向预训练对于语言表示的重要性。与 Radford 等人(2018 )使用单向语言模型进行预训练不同,BERT 使用掩码语言模型来实现预训练的深层双向表示。这也与 Peters 等人形成对比。 ---------------------------------------------------------------------------------------------------- ... ...
基于嵌入的方法:总结 测试结果表明,分块的粒度相对较粗。
图2还显示,这种方法是基于页面的,并未直接解决跨多页分块的问题。
总体而言,基于嵌入的方法的性能很大程度上依赖于嵌入模型。其实际效果有待未来评估。
基于模型的方法 朴素BERT 回顾BERT 的预训练过程。设计了一个二分类任务——下一句预测(NSP),旨在教会模型理解两个句子之间的关系。在此,两个句子同时输入BERT,模型预测第二个句子是否跟随第一个句子。
我们可以应用这一原理设计一种简单的分块方法。对于一个文档,先将其分割成句子。然后,使用滑动窗口将两个相邻句子输入BERT模型进行NSP判断,如图3所示:
如果预测得分低于预设阈值,则表明两个句子间的语义关系较弱。这可以作为文本分割点,如图3中句子2和句子3之间所示。
这种方法的优点在于,无需训练或微调即可直接使用。
然而,这种方法在确定文本分割点时仅考虑前后句子,忽略了更远段落的信息。此外,该方法的预测效率相对较低。
跨段注意力 论文《基于跨段注意力的文本分段》(Text Segmentation by Cross Segment Attention) 提出了三种关于跨段注意力的模型,如图4所示:
图4(a)展示了跨段BERT模型,该模型将文本分段定义为逐句分类任务。将潜在断点(两侧的**k**个标记)的上下文输入模型。与**[CLS]**对应的隐藏状态传递给softmax分类器,以决定是否在潜在断点处分段。
论文还介绍了另外两种模型。一种使用BERT模型获取每个句子的向量表示。然后将多个连续句子的这些向量表示输入到一个Bi-LSTM(图4(b))或另一个BERT(图4(c))中,以预测每个句子是否为文本分段边界。
当时,这三种模型取得了最先进的结果,如图5所示:
然而,截至目前,仅发现了该论文的训练实现 。尚未找到公开可用的推理模型。
SeqModel 跨段模型对每个句子独立进行向量化处理,不考虑任何更广泛的上下文信息。在论文“Sequence Model with Self-Adaptive Sliding Window for Efficient Spoken Document Segmentation ”中,进一步提出了SeqModel的改进方案。
SeqModel 采用BERT同时编码多个句子,在计算句子向量之前建模更长上下文中的依赖关系。随后预测每个句子后是否进行文本分段。此外,该模型利用自适应滑动窗口方法,在不牺牲准确性的前提下提升推理速度。SeqModel的示意图如图6所示。
SeqModel可通过ModelScope框架 使用。以下是提供的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from modelscope.outputs import OutputKeysfrom modelscope.pipelines import pipelinefrom modelscope.utils.constant import Tasksp = 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”进行任何分隔。
1 2 3 4 5 6 7 8 9 (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: 我们应该使用哪种检索粒度?》引入了一种新的检索单元——命题。命题被定义为文本中的原子表达,每个命题封装一个独特的细小事实,并以简洁、自包含的自然语言格式呈现。
那么,我们如何获取这个所谓的命题呢?在论文中,这是通过构建提示并与LLM交互来实现的。
LlamaIndex 和 Langchain 都已实现了相关算法,以下演示使用 LlamaIndex。
LlamaIndex 的实现思路是使用论文中提供的提示来生成命题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 PROPOSITIONS_PROMPT = PromptTemplate( """将“内容”分解为清晰简单的命题,确保它们在脱离上下文时可被解释。 1. 将复合句拆分为简单句。尽可能保持输入中的原始表述。 2. 对于任何伴随有附加描述信息的命名实体,将这些信息分离成独立的命题。 3. 通过添加必要的名词修饰语或整个句子,并替换代词(例如,“它”,“他”,“她”,“他们”,“这个”,“那个”)为它们所指实体的全名,来使命题脱离上下文。 4. 将结果呈现为字符串列表,格式为JSON。 输入: 标题: ¯Eostre。章节: 理论与解释,与复活节兔子的联系。内容: 最早关于复活节兔子(Osterhase)的记录是在1678年由医学教授Georg Franck von Franckenau在德国西南部记录的,但在其他地区的德国直到18世纪才为人所知。学者Richard Sermon写道,“春天经常在花园中看到兔子,因此可能为孩子们在那里隐藏的彩色鸡蛋的起源提供了一个方便的解释。另外,有一种欧洲传统认为兔子会下蛋,因为兔子的抓痕或形状与麦鸡的巢非常相似,两者都出现在草地上,并在春天首次被看到。在19世纪,复活节卡片、玩具和书籍的影响使得复活节兔子/兔子在整个欧洲流行起来。德国移民随后将这一习俗带到了英国和美国,在那里它演变成了复活节兔子。” 输出: [ "最早关于复活节兔子的记录是在1678年由Georg Franck von Franckenau在德国西南部记录的。", "Georg Franck von Franckenau是一位医学教授。", "关于复活节兔子的记录在其他地区的德国直到18世纪才为人所知。", "Richard Sermon是一位学者。", "Richard Sermon提出了一种关于兔子与复活节传统之间可能联系的假设。", "春天经常在花园中看到兔子。", "兔子可能为孩子们在花园中隐藏的彩色鸡蛋的起源提供了一个方便的解释。", "有一种欧洲传统认为兔子会下蛋。", "兔子的抓痕或形状与麦鸡的巢非常相似。", "兔子和麦鸡的巢都出现在草地上,并在春天首次被看到。", "在19世纪,复活节卡片、玩具和书籍的影响使得复活节兔子/兔子在整个欧洲流行起来。", "德国移民将复活节兔子/兔子的习俗带到了英国和美国。", "复活节兔子/兔子的习俗在英国和美国演变成了复活节兔子。" ] 输入: {node_text} 输出:""" )
在上一节基于嵌入的方法中,我们已经安装了 LlamaIndex 0.10.12 的关键组件。但如果我们想使用 DenseXRetrievalPack,还需要运行 pip install llama-index-llms-openai。安装后,当前的 LlamaIndex 相关组件如下:
1 2 3 4 5 6 (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** 是一个需要单独下载的包。这里在测试代码中直接下载。测试代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 from llama_index.core.readers import SimpleDirectoryReaderfrom llama_index.core.llama_pack import download_llama_packimport osos.environ["OPENAI_API_KEY" ] = "YOUR_OPENAI_KEY" DenseXRetrievalPack = download_llama_pack( "DenseXRetrievalPack" , "./dense_pack" ) dir_path = "YOUR_DIR_PATH" documents = SimpleDirectoryReader(dir_path).load_data() dense_pack = DenseXRetrievalPack(documents) response = dense_pack.run("YOUR_QUERY" )
通过测试代码可以发现,**class DenseXRetrievalPack** 的构造函数主要在使用。分析 DenseXRetrievalPack 的源代码 显得必要。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 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** 并非原始文本,它们已被重写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 > /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) 使用独立训练的从左到右和从右到左LM的浅层连接。' , 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_nodes** 和 **nodes** 之间的关系如图7所示,构建了一个从小到大的索引结构。
小到大索引结构通过self._gen_propositions 构建,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 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: 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的关键点。
最后,如有任何疑问,请在评论区指出。