LLM大模型: RAG的最优chunk方法 — 利用本地离线LLM的embedding实现Semantic Chunking
1、RAG整个流程的核心节点如下:
第一个重要的节点就是chunk。chunk最核心的目的就是把相同语义的token聚集在一起,不同语义的token互相分开,利于后续的retrieve和rerank。举个例子:今天天气很好,我和小明在一起打篮球。隔壁老王在家里看电视。小明的妈妈在家里做晚饭,晚上我去小明家吃饭。这段话明显表达了3个语义,chunk最优的结果就是分别把这3个语义表达分毫不差地分割开来!
如上图所示,chunk的方法有很多,最常见的就是按照固定长度chunk,但这样做容易把同样语义的文本拦腰截断;稍微变通一点就是按照句号、分号、问好、段落分割,但这样也容易把同样语义的句子分开,和按固定长度分割没有本质区别!怎么才能按照语义切割文本了?幸运的是:langChain和llamaIndex已经实现了Semantic Chunk。然而不幸的是:这两者提取语义embedding用的都是openAI的接口,要收费不说,大陆地区还面临被封API的风险,所以最好自己实现Semantic Chunk的功能!
2、上面说了,sematic chunk最核心的功能是把相同语义的token聚合在一起,一种可行的思路如下:
- 先简单粗暴按照句号、分号、问好、感叹号、换行符等分割文本,直观上符合用户习惯
- 计算每段分割好文本的embedding
- 直接用传统的聚类方法对所有的embedding聚类
这就O啦?显然没这么简单!一篇文章,语义相似的句子一般都在附近,相隔不会太远(这也是分割的基本原理和思路,只不过按照大小分割的方式太简单粗暴了),使用传统的聚类方式,可能把距离较远的句子聚在一起,严重不符合直觉,所以直接简单粗暴聚类的方式是行不通的,需要改进方案!
既然语义相似的句子一般都在附近,距离不会太远,那就近计算附近语句embedding的距离不久行了么?改进后的方案如下(核心思路是滑动窗口):
- 还是先简单粗暴按照句号、分号、问好、感叹号、换行符等分割文本,形成一个个的句子,用sen1、sen2、sen3. ... senN表示
- 从sen1开始,以此和前后一个句子组合,形成combined_sentence,比如sen1+sen2 = combined_sentence1,sen1+sen2+sen3=combined_sentence2,sen2+sen3+sen4=combined_sentence3,以此类推
- 以此计算combined_sentence1、combined_sentence2、combined_sentence3 ..... combined_sentenceN之间的相似度,如果相似度突然变化,那么新加入sen的语义肯定不同,从这里截断!举例如下:前面三个combined_sentence的距离都比较近,第4个combined_sentence和第三个的距离突然增加很多,说明sen4和sen1~sen3之间的语义肯定差异较大,可以从sen4开始分割,sen1~sen3合并成一个chunk!
3、代码实现如下:
(1)先把文章读进来:
with open('/root/huggingface/data/chunk_statage') as file: text = file.read() print(text)
(2)这里以中文为例,所以使用句号、分号、问好、感叹号、换行符等作为句子的分割标准:
import re # Splitting the text on '。' ';' '?' and '\n' single_sentences_list = re.split(r'[。;?!\n]+', text) print (f"{len(single_sentences_list)} senteneces were found")
(3)sen之间要上下拼接,list不好操作,给每个sen加上index
sentences = [{'sentence': x, 'index' : i} for i, x in enumerate(single_sentences_list)] sentences[:3]
(4)使用滑动窗口,把sen之间首尾拼接:
def combine_sentences(sentences, buffer_size=1): combined_sentences = [ ' '.join(sentences[j]['sentence'] for j in range(max(i - buffer_size, 0), min(i + buffer_size + 1, len(sentences)))) for i in range(len(sentences)) ] # 更新原始字典列表,添加组合后的句子 for i, combined_sentence in enumerate(combined_sentences): sentences[i]['combined_sentence'] = combined_sentence return sentences
sentences = combine_sentences(sentences)
每个sen都和自己前后的一个sen拼接,存放在combined_sentence字段:
(5)文本拼接完成,下一步就是embedding了。国产embedding比较好的有M3E和BGE,这里以M3E-large为例:先加载模型
from sentence_transformers import SentenceTransformer model = SentenceTransformer(model_name_or_path='/root/huggingface/m3e-large')
计算combined_sentence的embedding:
embeddings = model.encode([x['combined_sentence'] for x in sentences])
计算好的embedding写入对应位置:
for i, sentence in enumerate(sentences): sentence['combined_sentence_embedding'] = embeddings[i]
计算两个embedding之间的cosin相似度:
def cosine_similarity(vec1, vec2): """Calculate the cosine similarity between two vectors.""" dot_product = np.dot(vec1, vec2) norm_vec1 = np.linalg.norm(vec1) norm_vec2 = np.linalg.norm(vec2) return dot_product / (norm_vec1 * norm_vec2)
计算embedding之间的距离:
def calculate_cosine_distances(sentences): distances = [] for i in range(len(sentences) - 1): embedding_current = sentences[i]['combined_sentence_embedding'] embedding_next = sentences[i + 1]['combined_sentence_embedding'] # Calculate cosine similarity similarity = cosine_similarity(embedding_current, embedding_next) # Convert to cosine distance distance = 1 - similarity distances.append(distance) # Store distance in the dictionary sentences[i]['distance_to_next'] = distance return distances, sentences
distances, sentences = calculate_cosine_distances(sentences)
看看combined_sentence之间的距离分布:毕竟是同一片文章,之间的距离最大也没超过0.35;
那么问题来了,combined_sentence之间切分的阈值该怎么设置了?我暂时没有好的理论支持,只能人工多尝试几个,看看哪个效果最好。breakpoint_percentile_threshold是设置阈值的地方,这里以0.8为例:
import numpy as np plt.plot(distances) y_upper_bound = 0.15 plt.ylim(0, y_upper_bound) plt.xlim(0, len(distances)) # We need to get the distance threshold that we'll consider an outlier # We'll use numpy .percentile() for this breakpoint_percentile_threshold = 80 breakpoint_distance_threshold = np.percentile(distances, breakpoint_percentile_threshold) # If you want more chunks, lower the percentile cutoff plt.axhline(y=breakpoint_distance_threshold, color='r', linestyle='-') num_distances_above_theshold = len([x for x in distances if x > breakpoint_distance_threshold]) # The amount of distances above your threshold plt.text(x=(len(distances)*.01), y=y_upper_bound/50, s=f"{num_distances_above_theshold + 1} Chunks") # Then we'll get the index of the distances that are above the threshold. This will tell us where we should split our text indices_above_thresh = [i for i, x in enumerate(distances) if x > breakpoint_distance_threshold] # The indices of those breakpoints on your list # Start of the shading and text colors = ['b', 'g', 'r', 'c', 'm', 'y', 'k'] for i, breakpoint_index in enumerate(indices_above_thresh): start_index = 0 if i == 0 else indices_above_thresh[i - 1] end_index = breakpoint_index if i <= len(indices_above_thresh) - 1 else len(distances) plt.axvspan(start_index, end_index, facecolor=colors[i % len(colors)], alpha=0.25) plt.text(x=np.average([start_index, end_index]), y=breakpoint_distance_threshold + (y_upper_bound)/ 20, s=f"Chunk #{i}", horizontalalignment='center', rotation='vertical') # # Additional step to shade from the last breakpoint to the end of the dataset if indices_above_thresh: last_breakpoint = indices_above_thresh[-1] if last_breakpoint < len(distances): plt.axvspan(last_breakpoint, len(distances), facecolor=colors[len(indices_above_thresh) % len(colors)], alpha=0.25) plt.text(x=np.average([last_breakpoint, len(distances)]), y=breakpoint_distance_threshold + (y_upper_bound)/ 20, s=f"Chunk #{i+1}", rotation='vertical') plt.title("text Chunks Based On Embedding Breakpoints") plt.xlabel("Index of sentences in text (Sentence Position)") plt.ylabel("Cosine distance between sequential sentences") plt.show()
划分的结果如下:不同颜色就是不同的chunk
把同一个chunk的sen合并到一起:
# Initialize the start index start_index = 0 # Create a list to hold the grouped sentences chunks = [] # Iterate through the breakpoints to slice the sentences for index in indices_above_thresh: # The end index is the current breakpoint end_index = index # Slice the sentence_dicts from the current start index to the end index group = sentences[start_index:end_index + 1] combined_text = ' '.join([d['sentence'] for d in group]) chunks.append(combined_text) # Update the start index for the next group start_index = index + 1 # The last group, if any sentences remain if start_index < len(sentences): combined_text = ' '.join([d['sentence'] for d in sentences[start_index:]]) chunks.append(combined_text)
打印个结果看看:
for i, chunk in enumerate(chunks): print (f"Chunk #{i}") print (chunk) print ("\n")
最终的结果:文字部分我个人觉得还行,代码部分就不行了,待改进!
Chunk #0 在构建与LLM相关的应用时,分块(chunking)是将大段文本分解为较小段的过程 当我们使用LLM嵌入内容时,chunking是一项帮助优化向量数据库返回内容相关性的基本技术 在这篇博文中,我们将探讨它是否以及如何帮助提高LLM相关应用的效率和准确性 往向量数据库中索引的任何内容都需要首先向量化(称为嵌入,embedding) Chunk #1 分块的主要原因是确保我们向量化的内容的噪音尽可能少,并且具有语义相关性 例如,在语义搜索(semantic search)中,我们索引文档语料库 每个文档都包含有关特定主题的有价值的信息 通过应用有效的分块策略,可以确保搜索结果准确捕获用户查询的本质 区块太小或太大,可能会导致搜索结果不精确或错失显示相关内容的机会 根据经验,如果文本块在没有周围上下文的情况下对人类有意义,那么它对语言模型也有意义 因此,为语料库中的文档找到最佳区块大小对于确保搜索结果准确且相关至关重要 另一个例子是会话代理(conversational agents) Chunk #2 我们使用向量化的块来构建基于知识库的会话代理的上下文,该知识库使代理基于受信任的信息 在这种情况下,对分块策略做出正确的选择很重要,原因有两个:首先,它将确定上下文是否真正与我们的提示(prompt)相关 Chunk #3 其次,它将确定是否能够在将检索到的文本发送到外部模型提供者(例如OpenAI)之前将其放入上下文中,因为我们可以为每个请求发送的token数量受到限制 在某些情况下,例如将 GPT-4 与 32k 上下文窗口一起使用时,拟合区块可能不是问题 尽管如此,使用非常大的块可能会对从向量数据库返回的结果的相关性产生不利影响 我们将探讨几种分块方法,并讨论在选择分块大小和方法时应考虑的权衡 Chunk #4 最后,我们将提供一些建议,以确定适合您的应用的最佳区块大小和方法 当我们嵌入内容时,我们可以根据内容是短(如句子)还是长(如段落或整个文档)来预测不同的行为 当嵌入句子时,生成的向量侧重于句子的特定含义 与其他句子嵌入相比,比较自然会在该级别上进行 这也意味着嵌入可能会错过段落或文档中更广泛的上下文信息 嵌入整个段落或文档时,嵌入过程会考虑整体上下文以及文本中句子和短语之间的关系 这可以产生更全面的矢量表示,从而捕获文本的更广泛含义和主题 另一方面,较大的输入文本大小可能会引入干扰或稀释单个句子或短语的重要性,从而在查询索引时更难找到精确匹配项 查询的长度也会影响嵌入之间的相互关系 较短的查询(例如单个句子或短语)将专注于细节,并且可能更适合与句子级嵌入进行匹配 跨越多个句子或段落的较长查询可能更符合段落或文档级别的嵌入,因为它可能正在寻找更广泛的上下文或主题 索引也可能是非同类的,并且包含不同大小的块的嵌入 这可能会在查询结果相关性方面带来挑战,但也可能会产生一些积极的后果 一方面,由于长内容和短内容的语义表示之间存在差异,查询结果的相关性可能会波动 另一方面,非同构索引可能会捕获更广泛的上下文和信息,因为不同的块大小表示文本中的不同粒度级别 这可以更灵活地适应不同类型的查询 几个变量在确定最佳分块策略方面发挥作用,这些变量因用例而异 以下是需要牢记的一些关键方面:被索引的内容的性质是什么 您是处理较长的文档(如文章或书籍)还是较短的内容(如推文或即时消息) Chunk #5 答案将决定哪种模型更适合您的目标,从而决定应用哪种分块策略 您使用的是哪种嵌入模型,它在哪些块大小上表现最佳 Chunk #6 例如,sentence-transformer[1]模型在单个句子上效果很好,但像text-embedding-ada-002[2]这样的模型在包含 256 或 512 个token的块上表现更好 您对用户查询的长度和复杂性有何期望 Chunk #7 它们是简短而具体的还是冗长而复杂的 这也可能会告知您选择对内容进行分块的方式,以便嵌入式查询和嵌入式区块之间有更紧密的相关性 检索到的结果将如何在您的特定应用程序中使用 Chunk #8 例如,它们是否用于语义搜索、问答、摘要或其他目的 例如,如果你的结果需要被输入到另一个具有令牌限制的LLM,你必须考虑到这一点,并根据你想要适应LLM请求的块数来限制块的大小 回答这些问题将允许您开发平衡性能和准确性的分块策略,这反过来又将确保查询结果更具相关性 有不同的分块方法,每种方法可能适用于不同的情况 Chunk #9 通过检查每种方法的优点和缺点,我们的目标是确定应用它们的正确方案 固定大小的分块 这是最常见和最直接的分块方法:我们只需决定块中的代币数量,以及它们之间是否应该有任何重叠 通常,我们希望在块之间保持一些重叠,以确保语义上下文不会在块之间丢失 在大多数常见情况下,固定大小的分块将是最佳路径 与其他形式的分块相比,固定大小的分块在计算上便宜且易于使用,因为它不需要使用任何 NLP 库 下面是使用 LangChain 执行固定大小的分块的示例: text = "..." # your text Chunk #10 from langchain.text_splitter import CharacterTextSplitter text_splitter = CharacterTextSplitter( Chunk #11 separator = "\n\n", Chunk #12 chunk_size = 256, chunk_overlap = 20 Chunk #13 ) docs = text_splitter.create_documents([text])
参考:
1、https://python.langchain.com/v0.1/docs/modules/data_connection/document_transformers/semantic-chunker/
2、https://blog.csdn.net/star1210644725/article/details/136241523 RAG中如何解决上下文知识连贯性问题
3、https://www.bilibili.com/video/BV1dr421x7Su/?spm_id_from=333.337.search-card.all.click&vd_source=241a5bcb1c13e6828e519dd1f78f35b2 一站帮你选择RAG中的文本切分策略
4、https://github.com/blackinkkkxi/RAG_langchain/blob/main/chunsize/chunk_size.ipynb
5、https://hasanaboulhasan.medium.com/the-best-text-chunking-method-f5faeb243d80