上一篇我们了解了向量知识库的常见技术,这篇我们聊聊使用分割文档来优化向量知识库的性能。

分割文档

在 chunk 向量化嵌入知识库之前,文档的分割是非常重要的,因为如果分割的粒度太粗,那么向量嵌入的维度会非常大,导致计算量增大,如果分割的粒度太细,那么向量嵌入的维度会非常小,导致信息丢失。

但实际上的应用中,文档的分割粒度需要根据具体的业务场景来确定,比如在问答系统中,对准确性要求较高,那么分割的粒度需要细致,而在百亿级文档的搜索引擎中,对召回率要求较高,那么分割的粒度需要粗粒度。

Langchain 里面已经集成了很多分割文档的方法,如 CharacterTextSplitterNLTKTextSplitterSpacyTextSplitterRecursiveCharacterTextSplitterMarkdownTextSplitterLatexTextSplitter

固定大小分块

这是最常见、最直接的分块方法:

我们只需决定块中的 tokens 的数量,以及它们之间是否应该有任何重叠。一般来说,我们会在块之间保持一些重叠,以确保语义上下文不会在块之间丢失。在大多数情况下,固定大小的分块将是最佳方式。与其他形式的分块相比,固定大小的分块在计算上更加经济且易于使用,因为它在分块过程中不需要使用任何 NLP 库。

下面是一个使用 LangChain 执行固定大小块处理的示例:

1
2
3
4
5
6
7
8
9
text = "..."  # your 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])

句分割——Sentence splitting

正如我们之前提到的,许多模型都针对 Embedding 句子级内容进行了优化。当然,我们会使用句子分块,有几种方法和工具可以做到这一点,包括:

  • Naive splitting: 最幼稚的方法是用句号 (。) 和”换行”来分割句子。虽然这可能是快速和简单的,但这种方法不会考虑到所有可能的边缘情况。这里有一个非常简单的例子:
1
2
text = "..."  # 你的文本
docs = text.split(".")
  • NLTK: 自然语言工具包 (NLTK) 是一个流行的 Python 库,用于处理自然语言数据。它提供了一个句子标记器,可以将文本分成句子,帮助创建更有意义的分块。例如,要将 NLTK 与 LangChain 一起使用,您可以这样做:
1
2
3
4
5
text = "..."  # 你的文本
from langchain.text_splitter import NLTKTextSplitter

text_splitter = NLTKTextSplitter()
docs = text_splitter.split_text(text)
  • spaCy: spaCy 是另一个用于 NLP 任务的强大 Python 库。它提供了一个复杂的句子分割功能,可以有效地将文本分成单独的句子,从而在生成的块中更好地保存上下文。例如,要将 spaCy 与 LangChain 一起使用,您可以这样做:
1
2
3
4
5
text = "..."  # 你的文本
from langchain.text_splitter import SpacyTextSplitter

text_splitter = SpacyTextSplitter()
docs = text_splitter.split_text(text)

递归分割

递归分块使用一组分隔符以分层和迭代的方式将输入文本分成更小的块。如果分割文本开始的时候没有产生所需大小或结构的块,那么这个方法会使用不同的分隔符或标准对生成的块递归调用,直到获得所需的块大小或结构。这意味着虽然这些块的大小并不完全相同,但它们仍然会逼近差不多的大小。

这里有一个例子,如何配合 LangChain 使用递归分块:

1
2
3
4
5
6
7
8
9
10
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: Markdown 是一种轻量级的标记语言,通常用于格式化文本。通过识别 Markdown 语法 (例如,标题、列表和代码块),您可以根据其结构和层次结构智能地划分内容,从而生成语义更连贯的块。例如:
1
2
3
4
5
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: LaTeX 是一种文档准备系统和标记语言,通常用于学术论文和技术文档。通过解析 LaTeX 命令和环境,创建尊重内容逻辑组织的块 (例如,节、子节和方程),从而产生更准确和上下文相关的结果。例如:
1
2
3
4
5
from langchain.text_splitter import LatexTextSplitter

latex_text = "..."
latex_splitter = LatexTextSplitter(chunk_size=100, chunk_overlap=0)
docs = latex_splitter.create_documents([latex_text])

原文来源于此处

他们都属于硬性分割,即按照固定的规则进行分割,对于语义等复杂的情况,他们无法处理,特别是上下文有关联的情况。

余弦计算分割

余弦计算分割是一种基于余弦相似度的分割方法。它通过计算文本中每个句子与其他句子之间的余弦相似度,来确定哪些句子应该被分割成独立的块。这种方法可以更好地保留文本的语义结构,从而提高向量嵌入的质量。

先把整理内容按句子分割,然后计算每个句子与其他句子之间的余弦相似度,然后根据相似度进行分割。
相似度相近的句子会被分割成一个块,相似度相差较大的句子会被分割成不同的块。

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
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import nltk
from nltk.tokenize import sent_tokenize

# 确保已下载必要的nltk资源
nltk.download('punkt')

def cosine_similarity_chunking(text, threshold=0.3, min_chunk_size=2):
# 分割成句子
sentences = sent_tokenize(text)

# 创建TF-IDF向量
vectorizer = TfidfVectorizer()
tfidf_matrix = vectorizer.fit_transform(sentences)

# 计算余弦相似度
cosine_sim = cosine_similarity(tfidf_matrix, tfidf_matrix)

# 基于相似度分组句子
chunks = []
current_chunk = [0] # 从第一个句子开始

for i in range(1, len(sentences)):
# 计算当前句子与当前chunk中所有句子的平均相似度
avg_sim = np.mean([cosine_sim[i][j] for j in current_chunk])

if avg_sim >= threshold:
# 如果相似度高,添加到当前chunk
current_chunk.append(i)
else:
# 如果相似度低,创建新的chunk
if len(current_chunk) >= min_chunk_size:
chunks.append([sentences[j] for j in current_chunk])
current_chunk = [i]

# 添加最后一个chunk
if current_chunk:
chunks.append([sentences[j] for j in current_chunk])

# 将chunks中的句子合并成文本块
text_chunks = [' '.join(chunk) for chunk in chunks]

return text_chunks

# 使用示例
text = "这是一个示例文本。这些句子彼此相关。这三个句子应该在同一个块中。" \
"这是另一个主题的开始。这个主题与前面的不同。这些句子应该形成另一个块。"

chunks = cosine_similarity_chunking(text)
for i, chunk in enumerate(chunks):
print(f"Chunk {i+1}: {chunk}")

LLM 辅助分割

使用 LLM 辅助分割是一种基于 LLM 的分割方法。它通过使用 LLM 来判断哪些句子应该被分割成独立的块。这种方法可以更好地保留文本的语义结构,从而提高向量嵌入的质量。

如:JinaAI 、AIcrawler 等可以调用的来辅助。

此处我先使用 prompt 来辅助分割(此方式消耗大,仅做原理展示)。

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import openai
from langchain.text_splitter import TextSplitter
from typing import List

class LLMTextSplitter(TextSplitter):
def __init__(self, api_key, chunk_size=1000, chunk_overlap=200, model="gpt-3.5-turbo"):
super().__init__(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
openai.api_key = api_key
self.model = model

def split_text(self, text: str) -> List[str]:
# 首先使用基本方法进行初步分割
sentences = text.split(". ")
sentences = [s + "." if not s.endswith(".") else s for s in sentences]

# 如果文本很短,直接返回
if len(sentences) <= 3:
return [text]

chunks = []
current_chunk = []
current_length = 0

for i in range(len(sentences)):
current_chunk.append(sentences[i])
current_length += len(sentences[i])

# 当达到一定长度或最后一个句子时
if current_length >= self.chunk_size or i == len(sentences) - 1:
# 使用LLM判断这组句子是否应该成为一个语义块
if len(current_chunk) > 1:
chunk_text = " ".join(current_chunk)
prompt = f"""
我有以下一段文本:
---
{chunk_text}
---
请分析这段文本,并判断它是否包含一个完整的语义单元或主题。
如果是一个完整的单元,返回"完整"。
如果包含多个不相关的主题或语义单元,请指出在哪里进行分割,返回句子的索引值(从0开始)。
"""

response = openai.ChatCompletion.create(
model=self.model,
messages=[{"role": "system", "content": "你是一个文本分析助手,擅长分析文本的语义结构。"},
{"role": "user", "content": prompt}]
)

llm_advice = response.choices[0].message.content

if "完整" in llm_advice:
chunks.append(" ".join(current_chunk))
else:
# 尝试从LLM响应中提取分割点
try:
split_points = [int(s) for s in llm_advice.split() if s.isdigit()]
if split_points:
# 基于LLM建议的点分割
last_point = 0
for point in split_points:
if point > last_point:
chunks.append(" ".join(current_chunk[last_point:point]))
last_point = point
if last_point < len(current_chunk):
chunks.append(" ".join(current_chunk[last_point:]))
else:
# 如果无法解析分割点,则整体作为一个块
chunks.append(" ".join(current_chunk))
except:
# 出错时保持原样
chunks.append(" ".join(current_chunk))
else:
chunks.append(" ".join(current_chunk))

# 重置当前块,但保留overlap
overlap_start = max(0, len(current_chunk) - self.chunk_overlap)
current_chunk = current_chunk[overlap_start:]
current_length = sum(len(s) for s in current_chunk)

return chunks

# 使用示例
text = """机器学习是人工智能的一个分支,它使用统计技术使计算机系统能够"学习"并逐渐改进特定任务的性能,而无需明确编程。
机器学习算法根据样本数据(称为"训练数据")构建数学模型,以便在没有明确编程的情况下进行预测或决策。
机器学习算法被广泛应用于各种任务,如电子邮件过滤和计算机视觉等应用程序。

深度学习是机器学习的一个子领域,它使用类似于人脑结构的神经网络进行学习。
这些神经网络能够处理大量数据并识别其中的模式。
深度学习已经在图像识别、语音识别和自然语言处理等领域取得了巨大的成功。"""


splitter = LLMTextSplitter(api_key="your_openai_api_key")
chunks = splitter.split_text(text)

for i, chunk in enumerate(chunks):
print(f"Chunk {i+1}: {chunk}")