RAG技术构建企业级文档问答系统的Late Chunking切分
LateChunking将向量化置于切分之前,使片段向量融合上下文语义,以解决代词指代不明问题。虽在相似度计算中表现优于传统方法,但实际应用效果不佳,短句易与其他句子混淆,未能稳定提升检索质量。
Jina在2024年推出的Late Chunking(延迟切分)技术,为文本切分领域带来了一个颇具新意的思路——先整体理解语义再执行切分,而非传统的先切分后理解。本文是中文环境下首篇系统性介绍该方法的文章,力求以最简洁的方式讲清其动机、原理及实际效果。

出发点
写作时使用代词是非常自然的语言习惯,但一旦进行文本切分,问题便随之而来。Jina提出的问题其实很常见:某些片段里只剩下代词,而它所指向的对象却位于前一个片段中。检索时,如果用户询问“柏林有多少居民”,而知识库里只有一个片段写着“Its more than 3.85 million inhabitants”,模型很难把这个片段和柏林联系起来——甚至很难准确召回这个片段。
举一个具体例子:如果文档按某种方式切分,第二个片段里充斥着“Its”和“it”这类指代模糊的内容,检索失败的概率会非常高。即便模型恰好召回该片段,它也无法明确“Its”指代的是Berlin。这就导致,答案明明存在于库中,却因为切分方式不当,让答案“哑巴”了。
这里有一个值得尝试的方向:在切分前,统一进行一次指代消解,将代词替换回它所指代的对象。比如上面的例子,第二段如果变为“Berlin's more than 3.85 million inhabitants…”,检索时就不会出现代词问题。感兴趣的读者可以拿这个思路做个实验,看看实际效果如何。
核心原理
RAG的标准流程是先切分、再向量化。Jina的方案则反向操作——先把整段文本向量化,随后再切分。这一做法被称为Late Chunking(延迟切分),关键在于“晚”字——向量化步骤被挪到了切分之前。
具体操作上,先将知识库中的一整篇文档送入Embedding模型,得到每个token位置的Embedding向量。然后,按照选定的切分方式(按句子、按token长度等),取特定范围内的这些向量做平均,从而获得该片段的最终Embedding向量。
举个例子:“DeepSeek是由杭州幻方量化所成立的子公司所开发的大语言模型。它真的很强大!”
假设这句话经过tokenize后得到23个token:
[, , , , , , , , , , , , , , , , , , , , , , ]
对Embedding模型设置输出每个位置的hidden state(即Token Embedding),假设维度是1024,那么“DeepSeek”这个位置就对应一个1024维的向量,“是”这个位置也一样。关键点在于,“它”这个token的hidden state因为在整句上下文中计算出来,已经融合了整个句子的信息——它“知道”自己指代的是DeepSeek。如果按句子切分,“它真的很强”对应的位置(第19-23个token)的hidden state做平均,得到的向量实际表达的是“DeepSeek真的很强大”这个语义——这正是我们想要的效果。
这里需要澄清几个细节:
- 为什么只取一整篇文档,而不是整个知识库?因为跨文档使用代词指代的现象在实际中极少出现。Late Chunking要解决的是切分导致的指代不明问题,而不是跨文档的指代问题。
- 最后是怎么切分的?Jina官方实现提供了几种方式:按语义切分、按句子切分、按token长度切分。切分后,每个片段的Embedding就是该片段所有Token Embedding的均值。
- 一整篇文档过Embedding模型,会不会超长?Jina要求尽量使用支持长输入的Embedding模型。如果仍然超长怎么办?按模型支持的最大长度(比如8192)先切分,然后把这几个8192长度的向量拼接起来,再按前述方法获取片段的Embedding。这里涉及的操作细节不少:切分标准是按token长度而非句子长度,字符索引要和token索引对应,还要考虑模型第一个位置是否有特殊字符需要移除。
实验结果
官方用“Berlin”作为Query,与下面三个句子分别计算相似度。每个句子都用两种方式做向量化:传统方法(先切分再向量化)和Late Chunking。
第一个句子不含指代问题,两种方法计算的余弦相似度非常接近,符合预期。第二个句子中有“Its”和“it”,第三个句子中有“The city”,传统方法计算的余弦相似度明显较低,而Late Chunking方法则保持了较高的相似度——这正是Late Chunking的核心优势所在。
| 文本内容 | 传统方法相似度 | 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后,第二个句子在候选中会更靠前,被召回的概率更大。而传统方法可能会把这个句子排在很靠后的位置,导致答案错失。
实际效果
从动机上看,这个思路站得住脚,原理也说得很通,但实际的实验结果却有些尴尬——效果并不理想。需要说明的是,这里的测试并非完全控制变量:除了切分方法不同外,向量模型也不同,但生成模型和评估模型与其他实验保持一致。
核心代码实现
Late Chunking最核心的部分,其实不是切分动作在前还是后,而是片段中的向量表示要能融合上下文。官方提供的代码是按英文句子、token数等方式切分,这与中文习惯差异较大,因此本文的实现仍然按换行进行切分,但每个片段的Embedding使用的是融合了整个文档语义信息的向量。
def document_to_token_embeddings(model, tokenizer, document, batch_size=256):
tokenized_document = tokenizer(document, return_tensors="pt")
tokens = tokenized_document.tokens()
outputs = []
for i in tqdm(range(1, len(tokens), batch_size)):
start = i
end = min(i + batch_size, len(tokens))
batch_inputs = {k: v[:, start:end].to(device) for k, v in tokenized_document.items()}
with torch.no_grad():
model_output = model(**batch_inputs)
outputs.append(model_output.last_hidden_state)
model_output = torch.cat(outputs, dim=1)
return model_output
def late_chunking(token_embeddings, span_annotation, max_length=2048):
outputs = []
for embeddings, annotations in zip(token_embeddings, span_annotation):
if max_length is not None:
annotations = [
(start, min(end, max_length - 1))
for start, end in annotations if start < (max_length - 1)
]
pooled_embeddings = []
for idx, (start, end) in enumerate(annotations):
if (end - start) >= 1:
pooled_embeddings.append(
embeddings[start:end].mean(dim=0).cpu().numpy()
)
else:
pooled_embeddings.append([])
pooled_embeddings = [
embedding / np.linalg.norm(embedding) for embedding in pooled_embeddings
]
outputs.append(pooled_embeddings)
return outputs
# 构建span_annotations的示例逻辑
span_annotations = []
doc_input_ids = doc_tokens['input_ids'][0]
start_pos = 1
seperator_len = len(tokenizer('\n', return_tensors='pt')['input_ids'][0]) - 2
for chunk_idx, chunk in enumerate(chunks):
chunk_input_ids = tokenizer(chunk, return_tensors='pt')['input_ids'][0][1:-1]
chunk_token_len = len(chunk_input_ids)
if (doc_input_ids[start_pos: start_pos + chunk_token_len] == chunk_input_ids).detach().numpy().mean() != 1.0:
start_pos += 1
if (doc_input_ids[start_pos: start_pos + chunk_token_len] == chunk_input_ids).detach().numpy().mean() == 1.0:
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=256)
late_embeddings = late_chunking(document_embeddings, [span_annotations])
结果分析与讨论
考虑到测试集是中文的,而官方代码是英文的,最初怀疑是不是实现有bug。但用英文数据进行分析后,发现同样的问题依然存在。
这里只展示最关键的分析:对于一个知识库片段,用自身作为Query去检索,如果只保留Top1结果,绝大多数情况下应该检索到自身才对——但在Late Chunking中并非如此。
分析时,使用维基百科中DeepSeek词条的部分文本作为知识库,用官方代码切分得到片段。然后拿每个片段作为Query,过Embedding模型得到查询向量,与所有知识库片段两两计算相似度。结果发现,最相似的片段并不总是自己,前5个句子似乎都和第一个句子最相似。
原因其实很简单:Late Chunking是将整个片段每个位置的hidden state做平均,这意味着短句和代词较多的句子,更容易与其他句子相似。从句子长度的分析来看,长句普遍都能找到自己最相似,而短句则倾向于和其他句子相似。这一点不难理解——短句往往需要借助上下文信息,本身代词相对较多,平均后的向量容易被“拉向”其他相似意义的句子。
从RAG全流程的评估结果和英文数据的分析来看,Late Chunking并非一种非常通用、能稳定提升切分效果的方法。欢迎各位读者尝试这套代码,如果发现其中的bug,也欢迎反馈讨论。

