1、分块在不同应用场景的作用
语义搜索
在语义搜索中,索引一组文档,每个文档包含特定主题的有价值信息。通过应用有效的分块策略,可以确保搜索结果准确捕捉用户查询的核心。分块的大小和方式直接影响搜索结果的准确性和相关性:
- 分块过小:可能会丢失上下文信息,导致搜索结果无法准确理解用户查询的意图。
- 分块过大:可能会引入过多无关信息,稀释关键内容的重要性,降低搜索结果的精确度。
一般来说,如果一段文本在没有上下文的情况下对人类有意义,那么它对语言模型也是有意义的。因此,找到文档库中的最佳分块大小至关重要。
对话代理
在对话代理中,使用嵌入的分块来构建对话代理的上下文,基于一个知识库,使代理能够基于可信信息进行对话。选择合适的分块策略非常重要,原因有二:
- 上下文的相关性:合适的分块策略可以确保上下文与提示真正相关,从而提高对话的质量和准确性。
- token数量限制:在将检索到的文本发送给外部模型提供商(如OpenAI)之前,需要考虑每次请求可以发送的token数量的限制。例如,使用具有32k上下文窗口的GPT-4时,分块大小可能不是问题,但当使用较小上下文窗口的模型时,过大的分块可能会超出限制,导致无法有效利用检索到的信息。 此外,使用非常大的分块可能会会引入过多噪声,降低检索结果的质量,进而对结果的相关性产生不利影响。
2、分块的嵌入行为分析
嵌入短内容(如句子)
当一个句子被嵌入时,生成的向量主要聚焦于该句子的具体含义。这是因为句子级别的嵌入模型通常会将注意力集中在句子内部的词汇和语法结构上,以提取其核心语义。例如,句子“苹果是一种水果”会被嵌入成一个向量,该向量主要表示“苹果”和“水果”之间的关系,以及“苹果”这一具体概念的特征。
优点
- 精确匹配:句子级别的嵌入能够精确捕捉句子的具体含义,因此在与其他句子嵌入进行比较时,可以更准确地识别出语义上的相似性或差异性。
- 适合细节查询:对于较短的查询,如单个句子或短语,句子级别的嵌入更容易找到与之匹配的内容。例如,当用户查询“苹果的营养价值”时,句子级别的嵌入可以快速定位到包含“苹果”和“营养”相关词汇的句子。
缺点
- 缺乏上下文信息:句子级别的嵌入可能会错过段落或文档中的更广泛上下文信息。例如,一个句子可能在孤立状态下意义明确,但在整个段落或文档中,它的含义可能会受到前后文的影响。
- 难以捕捉主题:句子级别的嵌入难以捕捉到文本的更广泛主题和整体结构。例如,在一个关于“健康饮食”的文档中,单独嵌入的句子可能无法体现出整个文档的核心主题。
嵌入长内容(如段落或整个文档)
当嵌入整个段落或文档时,嵌入过程会考虑整体上下文以及文本中句子和短语之间的关系。这会生成一个更全面的向量表示,捕捉到文本的更广泛含义和主题。例如,一个段落可能包含多个句子,这些句子共同表达了一个主题或观点,嵌入过程会将这些信息整合到一个向量中。
优点
- 捕捉主题和上下文:长内容的嵌入能够捕捉到文本的整体主题和上下文信息。例如,在一个关于“气候变化”的文档中,嵌入向量可以反映出整个文档的核心观点和论据。
- 适合主题查询:对于跨越多个句子或段落的较长查询,段落或文档级别的嵌入更适合匹配。这是因为长查询通常需要更广泛的上下文信息来理解其意图。例如,当用户查询“气候变化对全球生态系统的影响”时,段落或文档级别的嵌入可以更好地找到与之相关的长文本内容。
缺点
- 引入噪声:较大的输入文本大小可能会引入噪声或稀释单个句子或短语的重要性。例如,一个段落中可能包含一些不相关的信息,这些信息会在嵌入过程中混入,导致向量表示不够精确。
- 难以精确匹配:由于长内容的嵌入包含了更多的信息,因此在查询索引时更难找到精确匹配。例如,一个包含多个主题的段落可能会与多个查询相关,但无法精确匹配到某个具体的查询。
查询长度对嵌入匹配的影响
查询的长度也会影响嵌入之间的关系:
- 短查询:较短的查询,如单个句子或短语,会集中在具体细节上,更适合与句子级别的嵌入进行匹配。这是因为短查询通常需要精确的语义匹配,而句子级别的嵌入能够提供更精确的语义表示。
- 长查询:跨越多个句子或段落的较长查询可能更适合与段落或文档级别的嵌入进行匹配。这是因为长查询通常需要更广泛的上下文信息来理解其意图,而段落或文档级别的嵌入能够提供更全面的语义表示。
非均匀索引的挑战与优势
索引也可能是非均匀的,包含不同大小的分块嵌入。这可能会在查询结果相关性方面带来挑战,但也可能产生一些积极的影响:
- 挑战:查询结果的相关性可能会因为长内容和短内容的语义表示之间的差异而波动。例如,一个查询可能与某个句子的嵌入高度相关,但与整个段落的嵌入相关性较低,这可能导致查询结果的不一致性。
- 优势:非均匀索引可能捕捉到更广泛的上下文和信息,因为不同的分块大小代表了文本中不同层次的粒度,这可能更灵活地适应不同类型的查询。例如,对于一些需要精确匹配的查询,句子级别的嵌入可以提供更好的结果;而对于一些需要更广泛上下文的查询,段落或文档级别的嵌入可以提供更全面的信息。
3、分块考虑因素
内容的性质
索引的内容是长文档还是短内容,决定了哪种模型更适合目标,进而影响分块策略的选择。
嵌入模型
不同的嵌入模型在不同的分块大小上表现最佳。例如,句子转换器模型适合单个句子,而像text-embedding-ada-002这样的通用模型在包含256或512个token的分块上表现更好,能够更好地捕捉段落或文档级别的上下文信息,适合需要更广泛语义理解的场景。
用户查询的长度和复杂性
用户查询的长度和复杂性会影响分块内容的方式,以便嵌入查询和嵌入分块之间有更紧密的相关性。如果用户查询通常很短且具体(如单个句子或短语),那么句子级别的分块可能更适合;如果用户查询通常较长且复杂(如跨越多个句子或段落),那么较大的分块(如段落或文档级别)可能更适合。
检索结果的用途
检索结果在特定应用中的用途,如语义搜索、问答、摘要等,也会影响分块策略。
语义搜索:如果检索结果用于语义搜索,需要考虑如何平衡分块大小以保留上下文信息和提高检索效率。较大的分块可以提供更全面的上下文,但可能会降低检索效率。
问答系统:如果检索结果用于问答系统,需要考虑如何确保分块与用户问题的语义相关性。较小的分块可以提供更精确的匹配,但可能会丢失上下文信息。
摘要生成:如果检索结果用于摘要生成,需要考虑如何选择分块大小以保留关键信息。较小的分块可以提供更精确的信息,但可能需要更多的分块来覆盖整个文档。
其他用途:如果检索结果需要输入到另一个具有token限制的LLM中(如OpenAI的GPT模型),则需要根据token限制来调整分块大小。例如,如果模型的上下文窗口为32k tokens,分块大小可以相对较大;但如果上下文窗口较小,则需要更小的分块。
4、经典分块方法 固定大小分块
最常见且直接的分块方法,该方法只需要通过决定分块中的token数量,并可选地决定分块之间是否有重叠来实现。通常,分块之间会保留一些重叠,以确保语义上下文不会在分块之间丢失。
优点:这种方法计算成本低且易于使用,不需要使用任何NLP库。
缺点:无法根据内容的语义结构进行动态调整。如果分块过大,可能会稀释关键信息;如果分块过小,可能会丢失上下文信息。
适用于大多数常见情况,尤其是当文本内容较为均匀且不需要复杂语义分析的情况。
复制text = "..." # 你的文本 from langchain.text_splitter import CharacterTextSplitter text_splitter = CharacterTextSplitter( separator = "\n\n", chunk_size = 256, chunk_overlap = 20 ) docs = text_splitter.create_documents([text])
5、经典分块方法 “内容感知”分块
“内容感知”分块方法利用分块内容的性质,应用更复杂的分块策略。这些方法通常基于自然语言处理技术,能够更好地保留语义结构。
句子分割
句子分割是将文本分割成句子级别的分块。许多模型都针对句子级别的嵌入进行了优化。
适用场景于需要精确句子匹配的场景(如问答系统、语义搜索)和文本内容较为均匀且以句子为单位的场景(如新闻文章、学术论文等)。
- 简单分割:通过句号(“.”)和换行符分割句子。快速且简单,但可能无法处理所有边缘情况。
text = "..." # 你的文本 docs = text.split(".")
- NLTK:自然语言工具包(NLTK)提供了一个句子分词器,可以准确地识别句子边界,避免在句子中间进行分块,从而确保每个分块都包含完整的句子。,
text = "..." # 你的文本 from langchain.text_splitter import NLTKTextSplitter # 创建 NLTKTextSplitter 实例 text_splitter = NLTKTextSplitter( chunk_size=256, # 分块大小 chunk_overlap=20 # 分块重叠部分 ) docs = text_splitter.split_text(text)
- spaCy:spaCy 提供了多种预训练模型,支持多种语言,这些模型能够准确地识别句子边界、词性等信息,并可以通过设置分块之间的重叠部分, 在一定程度上保留上下文信息。
text = "..." # 你的文本 from langchain.text_splitter import SpacyTextSplitter # 创建 SpacyTextSplitter 实例 text_splitter = SpacyTextSplitter( nlp=nlp, # spaCy 模型 chunk_size=256, # 分块大小 chunk_overlap=20 # 分块重叠部分 ) docs = text_splitter.split_text(text)
递归分块
递归分块使用一组分隔符以分层和迭代的方式将输入文本分割成较小的块。如果初始的分割尝试没有生成所需大小或结构的块,该方法会使用不同的分隔符或标准递归调用自身,直到达到所需的块大小或结构。
优点:可以根据不同的分隔符和标准动态调整分块大小,能够处理不同结构的文本。
缺点:递归调用可能导致较高的计算开销。
适用于需要处理复杂文本结构的场景(如长文档、多级标题的文档)和需要动态调整分块大小的场景(如根据内容的语义结构进行分块)。
复制text = "..." # 你的文本 from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( # 设置一个非常小的块大小,仅用于展示。 chunk_size = 256, chunk_overlap = 20 ) docs = text_splitter.create_documents([text])
专用分块
专用分块方法针对特定格式的文本(如Markdown和LaTeX)进行优化,以在分块过程中保留内容的原始结构。
优点:能够根据特定格式的语法和结构进行分块,生成更具语义连贯性的块。
缺点:仅适用于特定格式的文本,通用性较差。需要解析特定格式的语法和结构。
适用于特定格式的文本处理。
- Markdown
from langchain.text_splitter import MarkdownTextSplitter markdown_text = "..." markdown_splitter = MarkdownTextSplitter(chunk_size=100, chunk_overlap=0) docs = markdown_splitter.create_documents([markdown_text])
- LaTeX
from langchain.text_splitter import LatexTextSplitter latex_text = "..." latex_splitter = LatexTextSplitter(chunk_size=100, chunk_overlap=0) docs = latex_splitter.create_documents([latex_text])
语义分块
语义分块是一种新的实验性分块技术,通过语义分析创建由谈论相同主题或话题的句子组成的块。
优点:能够根据语义内容动态调整分块,生成更有意义的文本片段。
缺点:需要生成嵌入并进行语义分析,实现复杂度较高。效果依赖于嵌入模型的质量。
实现步骤:
- 将文档分割成句子。
- 创建句子组:对于每个句子,创建一个包含该句子前后一些句子的组。
- 为每个句子组生成嵌入,并将它们与它们的“锚定”句子相关联。
- 按顺序比较每个组之间的距离:较低的语义距离表明主题相同,较高的语义距离表明主题已发生变化。
from langchain.text_splitter import SemanticTextSplitter text = "..." # 加载预训练的语言模型 model = SentenceTransformer('all-MiniLM-L6-v2') # 创建 SemanticTextSplitter 实例 text_splitter = SemanticTextSplitter( model=model, # 预训练的语言模型 chunk_size=256, # 分块大小 chunk_overlap=20, # 分块重叠部分 semantic_threshold=0.75 # 语义距离阈值 ) docs = text_splitter.split_text(text)复制
5经典分块方法确定最佳分块大小 数据预处理:在确定最佳分块大小之前,需要对数据进行预处理以确保质量,如删除HTML标签或去除噪声元素。选择分块大小范围:根据内容的性质(如短消息或长文档)和嵌入模型的功能(如token限制),选择一系列潜在的分块大小进行测试。目标是找到保留上下文和保持准确性之间的平衡。评估每个分块大小性能:使用多个索引或具有多个命名空间的单个索引,为测试的分块大小创建嵌入并保存在索引中。然后运行一系列查询来评估质量,并比较不同分块大小的性能,通过迭代过程确定最适合内容和预期查询的分块大小。
6、Agentic Chunking
Agentic Chunking是一种基于大语言模型(LLM)的先进文本分块方法,旨在通过模拟人类在文本分割时的理解和判断,生成语义连贯的文本块。这种方法的核心在于关注文本中的“Agentic”元素(如人物、组织机构等),并将围绕这些元素相关的句子聚合在一起,形成有意义的文本块。
使用 agentic chunking 能够解决递归字符分割和语义分割的局限性。它不依赖于固定的 token 长度或语义意义的变化,而是主动评估每一句话,并将其分配到相应的文本块中。正因为如此,agentic chunking 能够将文档中相隔甚远的相关句子归入同一组。
优点:
- 语义连贯性:能够将文档中相隔较远但主题相关的句子归入同一组,生成语义连贯的文本块。
- 动态调整:根据文本内容动态调整分块大小,适应不同类型的文本。
- 上下文保留:通过句子独立化和语义评估,保留上下文信息,提升检索效率。
- 智能分块:通过 LLM 的智能判断,实现更高效的文本分块。
缺点:
- 成本较高:每次调用 LLM 都会消耗成本,且增加延迟。
- 处理速度较慢:由于需要进行复杂的语义评估,处理速度相对较慢。
- 资源需求高:需要足够的计算资源和内存来处理大型文档。
Agentic Chunking 特别适用于以下场景
非结构化文本:如客服对话记录、播客内容等。
主题反复变化的内容:如技术沙龙实录、会议记录等。
需要跨段落关联的 QA 系统:如智能问答系统、语义搜索等。
下面来看下该方法的实现:
句子独立化(Propositioning)
将文本中的每个句子独立化,确保每个句子都有自己的主语。例如,将句子 “He was leading NASA’s Apollo 11 mission.” 转换为 “Neil Armstrong was leading NASA’s Apollo 11 mission.”。这一步骤可以看作是对文档进行“句子级整容”,确保每个句子独立完整。
复制from langchain.chains import create_extraction_chain_pydantic from langchain_core.pydantic_v1 import BaseModel from typing import Optional from langchain.chat_models import ChatOpenAI import uuid import os from typing import List from langchain import hub from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI from pydantic import BaseModel # 定义pydantic模型 class Sentences(BaseModel): sentences: List[str] # 初始化 LLM 和提取链 obj = hub.pull("wfh/proposal-indexing") llm = ChatOpenAI(model="gpt-4o") extraction_llm = llm.with_structured_output(Sentences) extraction_chain = obj | extraction_llm # 调用提取链并处理文本 sentences = extraction_chain.invoke( """ On July 20, 1969, astronaut Neil Armstrong walked on the moon. He was leading the NASA's Apollo 11 mission. Armstrong famously said, "That's one small step for man, one giant leap for mankind" as he stepped onto the lunar surface. """ )
创建文本块
第一次启动时,没有任何文本块。因此,必须创建一个文本块来存储第一个 proposition。
复制from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI llm = ChatOpenAI(temperature=0) chunks = {} def create_new_chunk(chunk_id, proposition): summary_llm = llm.with_structured_output(ChunkMeta) summary_prompt_template = ChatPromptTemplate.from_messages( [ ( "system", "Generate a new summary and a title based on the propositions.", ), ( "user", "propositions:{propositions}", ), ] ) summary_chain = summary_prompt_template | summary_llm chunk_meta = summary_chain.invoke( { "propositions": [proposition], } ) chunks[chunk_id] = { "summary": chunk_meta.summary, "title": chunk_meta.title, "propositions": [proposition], }
更新文本块
每个后续的 proposition 都需要被添加到一个文本块中。当添加一个 proposition 时,文本块的标题和摘要可能并不完全准确地反映其内容。因此,要对它们进行重新评估,并在必要时进行重写。
复制from langchain_core.pydantic_v1 import BaseModel, Field class ChunkMeta(BaseModel): title: str = Field(descriptinotallow="The title of the chunk.") summary: str = Field(descriptinotallow="The summary of the chunk.") def add_proposition(chunk_id, proposition): summary_llm = llm.with_structured_output(ChunkMeta) summary_prompt_template = ChatPromptTemplate.from_messages( [ ( "system", "If the current_summary and title is still valid for the propositions return them." "If not generate a new summary and a title based on the propositions.", ), ( "user", "current_summary:{current_summary}\n\ncurrent_title:{current_title}\n\npropositions:{propositions}", ), ] ) summary_chain = summary_prompt_template | summary_llm chunk = chunks[chunk_id] current_summary = chunk["summary"] current_title = chunk["title"] current_propositions = chunk["propositions"] all_propositions = current_propositions + [proposition] chunk_meta = summary_chain.invoke( { "current_summary": current_summary, "current_title": current_title, "propositions": all_propositions, } ) chunk["summary"] = chunk_meta.summary chunk["title"] = chunk_meta.title chunk["propositions"] = all_propositions
语义评估与合并
LLM 会根据句子的语义内容,将其分配到现有的文本块中,或者在找不到合适的文本块时创建新的文本块。如果新的句子被添加到文本块中,LLM 可以更新文本块的摘要和标题,以反映新信息。
复制def find_chunk_and_push_proposition(proposition): class ChunkID(BaseModel): chunk_id: int = Field(descriptinotallow="The chunk id.") allocation_llm = llm.with_structured_output(ChunkID) allocation_prompt = ChatPromptTemplate.from_messages( [ ( "system", "You have the chunk ids and the summaries" "Find the chunk that best matches the proposition." "If no chunk matches, return a new chunk id." "Return only the chunk id.", ), ( "user", "proposition:{proposition}" "chunks_summaries:{chunks_summaries}", ), ] ) allocation_chain = allocation_prompt | allocation_llm chunks_summaries = { chunk_id: chunk["summary"] for chunk_id, chunk in chunks.items() } best_chunk_id = allocation_chain.invoke( {"proposition": proposition, "chunks_summaries": chunks_summaries} ).chunk_id if best_chunk_id not in chunks: best_chunk_id = create_new_chunk(best_chunk_id, proposition) return add_proposition(best_chunk_id, proposition)
7、总结
分块内容在大多数情况下相对简单,但当偏离常规路径时可能会带来挑战。没有一种适用于所有情况的分块解决方案,不同的用例需要不同的分块策略。通过理解分块策略的关键点和权衡,可以为特定的应用程序找到最适合的分块方法。