|
Jina在2024年推出了一种新的文本切分方法,在论文Late chunking: contextual chunk embeddings using long-context embedding models中做了系统阐述。 
来源:https://jina.ai/news/late-chunking-in-long-context-embedding-models/对中文实现Late Chunking,本文应该是第一篇,本文尽可能简单地介绍这项工作的动机、原理以及中文实现,并对结果做一个分析。 正常在撰写文章的过程中,使用一些代词是非常常见的,Jina指出,由于切分导致文本会切分到不同的片段中,而有些片段中只有代词而没有代词所指对象,这可能会导致检索失败。 例如下面的这段文本,比如文档按下图右侧的方式切分,用户的问题是“柏林有多少居民”,很明显使用第二个片段可以回答是3.85百万,但由于它使用的是Its,导致这个片段有可能是检索不到的,或者即使检索到了,大模型也没有办法明确知道Its指代的是Berlin。 所以说,此处给了我们一个论文思路,是不是可以在切分前,统一做一个指代消解,然后再切分,这样第二段就会变“Berlin’s more than 3.85 million inhabitants make it the European Union’s most populous city, as measured by population within city limits.” 这样检索时就不会有代词的问题了。感兴趣的朋友可以做个实验。

原理 RAG一般的流程是先做文本切分,然后再向量化,而Jina给出的解决方案正如其名——Late Chunking,它是先直接对整段文本做向量化,然后再切分。 具体是怎么做的呢,首先取知识库中的一整篇文档过Embedding模型,然后会得到一整篇文档每个token位置的Embedding向量,然后按照选取的切分方式,取特定范围的向量做平均,从而得到这个片段的Embedding向量。 例如“DeepSeek是由杭州幻方量化所成立的子公司所开发的大语言模型。它真的很强大!”, [,,,,,,,,,,,,,,,,,,,,,,] 共计23个token,对Embedding模型设置参数,输出每个问题的hidden state(也是个向量,也就是下图中的Token Embedding),如果维度是1024,那就意味着“DeepSeek”这个位置就会有一个1024维的向量,“是”这个位置也是一样。因为是作为一整个句子送入Embedding模型的,所以“它”这个token的hidden state其实融合了整个句子的信息,知道“它”其实指代的是DeepSeek。那如果我们按照句子切分,取“它真的很强”对应位置(上面19-23位置)的hidden state然后做平均,那平均后得到的向量其实表达的含义是“DeepSeek真的很强大”。 为什么是取一整篇文档而不是整个知识库:因为实际情况几乎不会出现跨文档还使用代词指代的情况,Late Chunking要解决的是切分导致代词不知其所指的问题,取整个知识库拼接没有意义; 所以最后是怎么切分的:Jina官方的代码仓库中有几种方法,分别是按语意、按句子、按token长度,切分之后片段的Embedding,是对片段切分点范围内的Token Embeddings取平均得到的; 一整篇文档过Embedding模型不担心超长吗:所以此处Jina要求尽量使用支持长输入的Embedding模型,如果还是超长,那就按照Embedding模型支持的最大长度(例如8192)先切分,假设向量维度是1024,按8192切分后得到7个片段,最终把这7个片段的8192 个维度为1024的向量拼接起来,然后还是按2中提到的方法获取片段的Embedding。这里面其实涉及到很多操作问题,首先比如是按8192切分,其实是token的长度是8192,而不是句子长度,其次是切分后字符的索引要和token的索引对应,最后如果超过Embedding模型的最大长度,需要考虑模型的第一个位置是否有特殊字符,要把它移除。

结果 官方使用了“Berlin”作为Query,分别计算与下面3个句子的相似度。对这3个句子分别使用了两种方式做向量化,一种是传统的先切分再向量化,记为Traditional,另一种是Late Chunking。 第一个句子不存在指代不明的问题,所以两种方法计算的余弦相似度很接近,这个是符合预期的。 第二个句子中有代词Its、it,第三个句子中有The city,使用传统方式计算的余弦相似度就相对较低,而使用Late Chunking方式计算的余弦相似度比较高,这就体现出了Late Chunking的优势。 | Text | Similarity Traditional | Similarity Late Chunking |
|---|
| Berlin is the capital and largest city of Germany, both by area and by population." | 0.84862185 | 0.849546 | | Its more than 3.85 million inhabitants make it the European Union's most populous city, as measured by population within city limits. | 0.7084338 | 0.82489026 | | The city is also one of the states of Germany, and is the third smallest state in the country in terms of area. | 0.7534553 | 0.84980094 |
相似度高意味着什么,意味知识库比较大时,如果输入“柏林的常驻人口有多少”,使用Late Chunking第2个句子会排在候选中更靠前的位置,从而有更大的概率被召回,而传统方法则会排在比较靠后的位置。 虽然动机看起来站得住脚,原理好像也说得通,但实验的结果却表现很差。后文会进行分析。此处不是完全控制变量实验,有两个变量,除了切分方法不同外,向量模型也不同,其余的生成模型、评估模型与其他实验保持一致。 
核心代码 本文代码已开源,地址在:https://github.com/Steven-Luo/MasteringRAG/blob/main/split/05_late_chunking.ipynb Late Chunking最核心的部分,其实不是切分动作在前还是在后,而是片段中的向量表示,要能融合上下文。源代码提供的是按照英文句子、token数等方式的切分,与中文习惯差异较大,所以本文中的实现还是按换行进行了切分,但每个片段的Embedding使用的是融合了整个文档语意信息的。
(model,tokenizer,document,batch_size=):
tokenized_document=tokenizer(document,return_tensors=)
tokens=tokenized_document.tokens()
outputs=[]
itrange(,(tokens),batch_size):
start=i
end=(i+batch_size,(tokens))
batch_inputs={k:v[:,start:end].to(device)k,vtokenized_document.items()}
torch.no_grad():
model_output=model(**batch_inputs)
outputs.append(model_output.last_hidden_state)
model_output=torch.cat(outputs,dim=)
model_output
(token_embeddings,span_annotation,max_length=):
outputs=[]
embeddings,annotations(token_embeddings,span_annotation):
(max_length):
annotations=[
(start,(end,max_length-))
(start,end)annotations
start<(max_length-)
]
pooled_embeddings=[]
idx,(start,end)(annotations):
chunks[idx]==tokenizer.decode(doc_input_ids[start:end]),
(end-start)>=:
pooled_embeddings.append(
embeddings[start:end].mean(dim=).cpu().numpy()
)
:
()
pooled_embeddings=[
embedding/np.linalg.norm(embedding)embeddingpooled_embeddings
]
outputs.append(pooled_embeddings)
outputs
span_annotations=[]
doc_input_ids=doc_tokens[][]
start_pos=
seperator_len=(tokenizer(,return_tensors=)[][])-
chunk_idx,chunk(chunks):
chunk_input_ids=tokenizer(chunk,return_tensors=)[][][:-]
chunk_token_len=(chunk_input_ids)
(doc_input_ids[start_pos:start_pos+chunk_token_len]==chunk_input_ids).detach().numpy().mean()!=:
start_pos+=
(doc_input_ids[start_pos:start_pos+chunk_token_len]==chunk_input_ids).detach().numpy().mean()==,chunk_idx
span_annotations.append((start_pos,start_pos+chunk_token_len))
start_pos+=chunk_token_len
document_embeddings=document_to_token_embeddings(model,tokenizer,processed_doc,batch_size=)
late_embeddings=late_chunking(document_embeddings,[span_annotations])考虑到我们的测试集是中文的,而Jina的官方代码是英文的,所以我一度以为是我的实现有bug,但对英文的数据分析后,发现同样存在一样的问题。 此处只展示最关键的分析,更多分析大家可以查看分析部分的源代码:https://github.com/Steven-Luo/MasteringRAG/blob/main/split/05_late_chunking_en_debug.ipynb 对于一个知识库片段,使用它自身作为Query去检索,如果只保留Top1,绝大多数情况下应该检索到它自身才对,而在Late Chunking中却不是这样。 下面的分析使用维基百科中DeepSeek词条的部分文本作为知识库,使用Jina公布的代码切分得到片段,然后分别拿每个片段作为Query,分别过Embedding模型得到Query的向量,与作为知识库片段的向量两两之间计算相似度,从这个结果来看,最相似的片段,并不总是自己,前5个句子,似乎都跟第一个句子最相似。 
由于Late Chunking是将整个片段每个位置的hidden state做平均,所以可以想想,短句、代词较多的句子,应该会跟其他句子更相似。简便起见,此处检查句子长度。 下图中,横轴的0表示以每个片段作为Query,最相似的不是自身,1表是是自身,从结果可以看出,长句普遍都和自身最相似,而短句则和其他句子最相似。这也好理解,因为短句大都要借助前面的内容作为上下文,其中会有相对较多的代词。 
从RAG全流程评估结果和英文的分析结果来看,这种方法似乎不是一种非常通用的能提升切分效果的方法,欢迎大家尝试,如果发现了我代码中的bug,欢迎反馈。 Langchain中使用Ollama提供的Qwen大模型进行Function Call实现天气查询、网络搜索Langchain中使用千问官方API进行Function Call实现天气查询、网络搜索使用Ollama提供的Llama3 8B搭建自己的斯坦福多智能体AI小镇使用Ollama提供的Qwen2 7B搭建自己的中文版斯坦福多智能体AI小镇数据准备 Baseline 评估 解析(1)使用MinerU将PDF转换为Markdown
检索优化(8)Step-Back Prompting检索优化(9)Parent Document Retriever检索优化(13)Contextual Retrieval检索优化(14)CRAG——自动判断是否联网检索的RAG
新架构(1)LightRAG 使用Flowise零代码构建RAG(1)——基础流程 使用Flowise零代码构建RAG(2)——HyDE 使用Flowise零代码构建RAG(3)——Reciprocal Rank Fusion |