如果你的 RAG 应用未能达到预期效果,或许是时候重新审视你的分块策略了。更好的分块意味着更精准的检索,最终带来更高质量的回复。
然而,分块技术并非“一招鲜吃遍天”,没有哪一种方法是绝对最优的。你需要根据项目的具体需求、文档特性以及预算等因素,综合考量并选择最适合的策略。
为什么分块质量直接影响 RAG 回复质量?
我相信,阅读本文的你对分块和 RAG 的基本概念已经有所了解。简单回顾一下,RAG 的核心思想是让 LLM 基于给定的上下文信息回答问题。这是因为,LLM 虽然知识储备丰富,但其知识更新存在滞后性,且无法直接访问私有数据。
RAG 通过在提示词中注入相关文档片段(即上下文),引导 LLM 在这些片段的基础上生成答案,弥补了 LLM 自身的不足。上下文的获取方式多种多样,例如数据库查询、互联网搜索、或从 PDF 文档中提取等。
构建高效 RAG 应用,会遇到两个关键挑战:
- LLM 的上下文窗口限制:早期的 LLM,如 GPT-2 和 GPT-3,上下文窗口较小,限制了单次可处理的文本量。虽然现在出现了支持更大上下文窗口的模型,但这并不意味着我们可以直接将整个文档塞入 LLM。
- 上下文噪声问题:即使 LLM 的上下文窗口足够大,如果提供的上下文信息中包含大量与问题无关的内容(噪声),也会影响 LLM 的理解和判断,导致回复质量下降甚至产生幻觉。
为了解决这些问题,ドキュメントのチャンキング技术应运而生。其核心思想是将大型文档拆分成更小的、语义连贯的片段(块),然后在检索阶段,只选取最相关的块作为上下文提供给 LLM。
文档分块的方法多种多样,简单的如按句子、段落分割,复杂的则有语义分块、Agentic 分块等。选择合适的分块策略至关重要,它直接影响 RAG 系统的检索效率和最终的回复质量。
本文将深入探讨几种更高级且实用的文档分块策略,帮助你构建更强大的 RAG 应用。我们将跳过简单的句子和段落分割,重点介绍在实际 RAG 应用中更有价值的技术。
接下来,我将详细介绍几种我学习和实践过的分块策略。
递归字符分割:快速且经济的基础方法
递归字符分割,你或许会觉得这是最基础的方法。的确,它很基础,但它依然是我认为最常用、性价比最高的分块技术之一。它易于理解、实现简单、速度快且成本低廉,尤其适合快速原型验证和对成本敏感的项目。
递归字符分割的核心思想是使用固定大小的滑动窗口,并允许窗口之间存在重叠。它从文档的起始位置开始,以预设的块大小和重叠字符数,不断滑动窗口,生成文本块。
下图展示了递归字符分割的工作原理:
递归字符分割的优势在于其简单性和高效性。它可以快速处理大型文档,在分钟级别内完成年度报告的分块。在 Langchain 中,实现递归字符分割非常简单:
from langchain.text_splitter import RecursiveCharacterTextSplitter
text = """
Hydroponics is an intelligent way to grow veggies indoors or in small spaces. In hydroponics, plants are grown without soil, using only a substrate and nutrient solution. The global population is rising fast, and there needs to be more space to produce food for everyone. Besides, transporting food for long distances involves lots of issues. You can grow leafy greens, herbs, tomatoes, and cucumbers with hydroponics.
"""
rc_splits = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
chunk_size=20, chunk_overlap=2
).split_text(text)
滑动窗口的变体
在实际应用中,滑动窗口的大小和滑动步长可以有多种变化,以适应不同的需求:
- 基于字符 vs. 基于 Token 的滑动窗口: 上述示例是基于字符的滑动窗口。也可以使用基于 Token 的滑动窗口,确保块大小更符合 LLM 的处理方式。Langchain 的 再帰的文字テキスト分割器 同时支持字符和 Token 两种模式。
- 动态窗口大小: 虽然固定窗口大小是递归字符分割的特点,但在某些场景下,也可以考虑动态调整窗口大小。例如,根据句子的长度或段落的结构,自适应地调整窗口大小,以保证块的语义完整性。
制限
递归字符分割是一种基于位置的分块方法。它简单地假设文档中位置相邻的文本在语义上也是相关的。然而,这种假设在很多情况下并不成立。
思考:为什么基于位置的分块会导致 RAGs 性能不佳?如何实现语义分块并获得更好的结果?
例如,同一章节中,作者可能先讨论多个不同的概念,最后才将它们关联起来。如果仅使用递归字符分割,可能会将本应属于同一语义单元的内容分割开,或者将语义无关的内容组合在一起,影响检索效果。
尽管存在局限性,但递归字符分割依然是 RAG 入门的理想选择。在原型开发阶段,或者对于结构简单的文档,它通常能提供令人满意的结果。如果你的项目对成本和速度有较高要求,递归字符分割也是一个值得考虑的方案。
语义分块:理解文本含义的分块方法
语义分块是一种更高级的分块策略,它不再仅仅依赖文本的位置信息,而是深入理解文本的语义含义。其核心思想是,在文档语义发生显著变化时进行分割,确保每个块都尽可能围绕单一主题。
下图展示了语义分块的工作原理:
与递归字符分割不同,语义分块生成的块长度通常是不固定的。它会根据语义完整性来确定块的边界,而不是预设固定的字符或 Token 数量。
实现语义分块的关键步骤
语义分块的难点在于如何程序化地理解句子的语义**。这通常借助モデルの埋め込み来实现。嵌入模型,如 OpenAI 的 テキスト埋め込み-3-大,可以将句子转换为向量表示,向量能够捕捉句子的语义信息。语义相似的句子,其向量在空间中也更接近。**
语义分块的典型流程包括以下五个步骤:
- 构建初始块:将文档初步分割成句子或段落,并将相邻的句子或段落组合成初始块。
- 生成块嵌入:使用嵌入模型,为每个初始块生成向量嵌入。
- 计算块间距离:计算相邻块之间的语义距离。常用的距离度量方法包括余弦距离等。距离越大,表示语义差异越大。
- 确定分割点:设定一个距离阈值。当相邻块之间的距离超过阈值时,将它们断开,形成新的语义块。阈值的选择需要根据具体文档和实验效果来调整。
- 可视化(可选):将块之间的距离可视化,有助于更直观地理解分块效果,并调整阈值。
以下代码展示了如何实现语义分块:
# Step 1 : Create initial chunks by combining concecutive sentences.
# ------------------------------------------------------------------
#Split the text into individual sentences.
sentences = re.split(r"(?<=[.?!])\s+", text)
initial_chunks = [
{"chunk": str(sentence), "index": i} for i, sentence in enumerate(sentences)
]
# Function to combine chunks with overlapping sentences
def combine_chunks(chunks):
for i in range(len(chunks)):
combined_chunk = ""
if i > 0:
combined_chunk += chunks[i - 1]["chunk"]
combined_chunk += chunks[i]["chunk"]
if i < len(chunks) - 1:
combined_chunk += chunks[i + 1]["chunk"]
chunks[i]["combined_chunk"] = combined_chunk
return chunks
# Combine chunks
combined_chunks = combine_chunks(initial_chunks)
# Step 2 : Create embeddings for the initial chunks.
# ------------------------------------------------------------------
# Embed the combined chunks
chunk_embeddings = embeddings.embed_documents(
[chunk["combined_chunk"] for chunk in combined_chunks]
# If you haven't created combined_chunk, use the following.
# [chunk["chunk"] for chunk in combined_chunks]
)
# Add embeddings to chunks
for i, chunk in enumerate(combined_chunks):
chunk["embedding"] = chunk_embeddings[i]
# Step 3 : Calculate distance between the chunks
# ------------------------------------------------------------------
def calculate_cosine_distances(chunks):
distances = []
for i in range(len(chunks) - 1):
current_embedding = chunks[i]["embedding"]
next_embedding = chunks[i + 1]["embedding"]
similarity = cosine_similarity([current_embedding], [next_embedding])[0][0]
distance = 1 - similarity
distances.append(distance)
chunks[i]["distance_to_next"] = distance
return distances
# Calculate cosine distances
distances = calculate_cosine_distances(combined_chunks)
# Step 4 : Find chunks with significant different to it's previous ones.
# ----------------------------------------------------------------------
import numpy as np
threshold_percentile = 90
threshold_value = np.percentile(cosine_distances, threshold_percentile)
crossing_points = [
i for i, distance in enumerate(distances) if distance > threshold_value
]
len(crossing_points)
# Step 5 (Optional) : Create a plot of chunk distances to get a better view
# -------------------------------------------------------------------------
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
def visualize_cosine_distances_with_thresholds_multicolored(
cosine_distances, threshold_percentile=90
):
# Calculate the threshold value based on the percentile
threshold_value = np.percentile(cosine_distances, threshold_percentile)
# Identify the points where the cosine distance crosses the threshold
crossing_points = [0] # Start with the first segment beginning at index 0
crossing_points += [
i
for i, distance in enumerate(cosine_distances)
if distance > threshold_value
]
crossing_points.append(
len(cosine_distances)
) # Ensure the last segment goes to the end
# Set up the plot
plt.figure(figsize=(14, 6))
sns.set(style="white") # Change to white to turn off gridlines
# Plot the cosine distances
sns.lineplot(
x=range(len(cosine_distances)),
y=cosine_distances,
color="blue",
label="Cosine Distance",
)
# Plot the threshold line
plt.axhline(
y=threshold_value,
color="red",
linestyle="--",
label=f"{threshold_percentile}th Percentile Threshold",
)
# Highlight segments between threshold crossings with different colors
colors = sns.color_palette(
"hsv", len(crossing_points) - 1
) # Use a color palette for segments
for i in range(len(crossing_points) - 1):
plt.axvspan(
crossing_points[i], crossing_points[i + 1], color=colors[i], alpha=0.3
)
# Add labels and title
plt.title(
"Cosine Distances Between Segments with Multicolored Threshold Highlighting"
)
plt.xlabel("Segment Index")
plt.ylabel("Cosine Distance")
plt.legend()
# Adjust the x-axis limits to remove extra space
plt.xlim(0, len(cosine_distances) - 1)
# Display the plot
plt.show()
return crossing_points
# Example usage with cosine_distances and threshold_percentile
crossing_poings = visualize_cosine_distances_with_thresholds_multicolored(
distances, threshold_percentile=bp_threashold
)
可视化距离的 Seborn 图表:
语义分块的优势与适用场景
语义分块的优势在于能够更好地捕捉文档的语义结构,将语义相关的文本片段聚合在一起,从而提高 RAG 系统的检索质量。它更适用于处理以下类型的文档:
- 结构复杂、主题多样的文档:例如,包含多个子主题的长篇报告、技术文档、书籍等。
- 语义跳跃性强的文档:作者在写作时,思路可能跳跃,语义分块能更好地适应这种写作风格。
相比递归字符分割,语义分块的计算成本更高**,速度也更慢。这主要是因为需要进行嵌入向量的计算和距离度量。因此,在资源有限的场景下,需要权衡考虑。
Agentic 分块:模拟人类理解的分块策略
Agentic 分块是更进一步的智能化分块方法。它借鉴了人类阅读和理解文档的方式,使用 LLM 作为 “智能 Agent” 来辅助分块。
人类的阅读习惯并非完全线性。我们在阅读时,会根据主题或概念进行跳跃式阅读,并在脑海中构建文档的逻辑结构。Agentic 分块试图模拟这种人类的理解过程。
与前两种方法不同,Agentic 分块不假设语义相似的内容在文档中是连续出现的**。它可以将文档中分散但语义相关的片段聚合在一起,形成更符合人类认知的语义块。
Agentic 分块的工作流程
Agentic 分块的核心思想是让 LLM 像人一样 “阅读” 文档,识别文档中的核心概念和主题,并基于这些概念和主题进行分块。典型的 Agentic 分块流程包括:
- 命题化 (Propositioning):将文档中的每个句子转化为更独立的 “命题” (Proposition)。例如,将指代不明的代词替换为指代对象,使每个句子都具备更完整的语义。
- 构建块容器:创建一个或多个 “块容器” (Chunk Container),用于存放语义相关的命题。每个块容器可以有一个标题和摘要,用于描述该容器的主题。
- Agent 驱动的命题分配:使用 LLM 作为 Agent,逐个 “阅读” 命题,并判断该命题应归属于哪个块容器。
- 如果 Agent 认为该命题与已有的某个块容器的主题相关,则将其加入该容器。
- 如果 Agent 认为该命题提出了一个新的主题,则创建一个新的块容器来存放该命题。
- 块容器后处理:对块容器进行后处理,例如,根据容器内的命题生成更精炼的块摘要和标题。
以下代码展示了 Agentic 分块的实现过程:
from langchain import hub
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.pydantic_v1 import BaseModel, Field
# Step 1: Convert paragraphs to propositions.
# --------------------------------------------
# Load the propositioning prompt from langchain hub
obj = hub.pull("wfh/proposal-indexing")
# Pick the LLM
llm = ChatOpenAI(model="gpt-4o")
# A Pydantic model to extract sentences from the passage
class Sentences(BaseModel):
sentences: List[str]
extraction_llm = llm.with_structured_output(Sentences)
# Create the sentence extraction chain
extraction_chain = obj | extraction_llm
# NOTE: text is your actual document
paragraphs = text.split("\n\n")
propositions = []
for i, p in enumerate(paragraphs):
propositions = extraction_chain.invoke(p
propositions.extend(propositions)
# Step 2: Create a placeholder to store chunks
chunks = {}
# Step 3: Deine helper classes and functions for agentic chunking.
class ChunkMeta(BaseModel):
title: str = Field(description="The title of the chunk.")
summary: str = Field(description="The summary of the chunk.")
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],
}
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
# Step 5: The main functino that creates chunks from propositions.
def find_chunk_and_push_proposition(proposition):
class ChunkID(BaseModel):
chunk_id: int = Field(description="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)
命题化的示例
原始文本
===================
A crow sits near the pond. It's a white one.
命题化文本
==================
A crow sits near the pond. This crow is a white one.
Agentic 分块的优势与挑战
Agentic 分块的最大优势在于其灵活性和智能化**。它可以更好地理解文档的深层语义结构,生成更符合人类认知的语义块,尤其擅长处理以下类型的文档:**
- 非线性结构文档:例如,思路跳跃、包含大量背景知识或隐含信息的文档。
- 需要跨段落、跨章节整合信息的文档:Agentic 分块可以将分散在文档不同位置的、但语义相关的片段聚合在一起。
然而,Agentic 分块也面临着一些挑战:
- 成本高昂:Agentic 分块需要频繁调用 LLM,计算成本和时间成本都较高。
- Prompt 工程依赖:Agentic 分块的效果很大程度上取决于 Prompt 的设计。需要精细地设计 Prompt,才能引导 LLM 有效地进行分块。
- 结果的不确定性:LLM 的输出可能存在一定的不确定性,导致分块结果不稳定。
Agentic 分块的应用场景
Agentic 分块虽然成本较高,但在一些对 RAG 效果要求极高的场景中,仍然是值得考虑的选择。例如:
- 专业领域的知识库:例如,法律、医学、金融等领域的知识库,对检索的准确性和召回率要求极高。
- 复杂问答系统:需要处理复杂的、需要推理和信息整合的问题的问答系统。
深入阅读:エージェンティック・チャンキング:AIエージェント駆動型意味論的テキストチャンキング
针对不同文档格式的分块策略
之前的讨论主要集中在纯文本的分块。但在实际应用中,我们 often 会遇到各种不同的文档格式,如 Markdown、HTML、PDF、代码等。针对不同的格式,需要采用更精细化的分块策略,以充分利用文档的结构信息。
Markdown 和 HTML 文档分块
Markdown 和 HTML 文档具有结构化的标签信息,例如标题、段落、列表、代码块等。我们可以利用这些标签作为分块的依据**,实现更精准的分块。**
- 按标题分块:将每个标题及其下的内容作为一个独立的块。这适用于结构清晰、章节分明的文档。
- 按段落分块:将每个段落作为一个块。段落通常是语义完整的单元,适合作为基本的分块单位。
- 组合分块:结合标题和段落进行分块。例如,先按一级标题分割文档,然后在每个一级标题下的内容中,再按段落分割。
示例:基于 HTML 标签的分块 (Python)
from bs4 import BeautifulSoup
html_text = """
<h1>Section 1</h1>
<p>This is the first paragraph of section 1.</p>
<p>This is the second paragraph of section 1.</p>
<h2>Subsection 1.1</h2>
<ul>
<li>List item 1</li>
<li>List item 2</li>
</ul>
"""
soup = BeautifulSoup(html_text, 'html.parser')
chunks = []
# 按 h1 标题分块
for h1_tag in soup.find_all('h1'):
chunk_text = h1_tag.text + "\n"
next_sibling = h1_tag.find_next_sibling()
while next_sibling and next_sibling.name not in ['h1', 'h2']: # 假设按 h1 和 h2 分级
chunk_text += str(next_sibling) + "\n" # 保留 HTML 标签,或 next_sibling.text 只保留文本
next_sibling = next_sibling.find_next_sibling()
chunks.append(chunk_text)
# 可以类似地处理 h2, p, ul, ol 等标签
print(chunks)
PDF 文档分块
PDF 文档的分块相对复杂,因为 PDF 本质上是一种排版格式,文本内容和排版信息混合在一起。直接按字符或行分割 PDF 可能破坏语义完整性。
PDF 分块的关键步骤通常包括:
- PDF 文本提取:使用 PDF 解析库 (如 PyPDF2, pdfminer, 或更专业的 unstructured.io) 从 PDF 文件中提取文本内容。
- 文本清洗和预处理:去除噪声字符、处理换行符、修复 OCR 错误等。
- 结构化信息提取:尝试从 PDF 中提取结构化信息,例如标题、页眉页脚、表格、列表等。一些高级的 PDF 解析库 (如 unstructured.io) 可以辅助进行结构化信息提取。
- 分块策略选择:基于提取的文本内容和结构信息,选择合适的分块策略 (如语义分块、递归字符分割等)。
注意を引く: unstructured.io 是一个强大的工具,可以处理多种文档格式 (包括 PDF),并尝试提取文档的结构化信息,简化 PDF 分块的流程。
代码文档分块
代码文档 (如 Python, Java, C++ 代码文件) 的分块,需要考虑代码的语法结构和逻辑单元。简单的按行或按字符分割代码,很可能破坏代码的完整性和可执行性。
代码文档分块的常见策略包括:
- 按函数/类分块:将每个函数或类作为一个独立的块。函数和类通常是代码的逻辑单元。
- 按代码块分块:识别代码中的逻辑代码块 (例如,循环、条件语句、try-except 块等),将每个代码块作为一个块。
- 结合代码注释分块:代码注释通常是对代码功能和逻辑的解释。可以将代码注释及其相关的代码块作为一个整体进行分块。
アーティファクト: 可以使用 tree-sitter 等语法解析工具,辅助代码的结构化分析和分块。Tree-sitter 可以解析多种编程语言的代码,并生成抽象语法树 (AST),方便我们根据代码的语法结构进行分块。
选择合适的分块大小和重叠
分块大小 (Chunk Size) 和重叠大小 (Chunk Overlap) 是分块策略中两个重要的参数,它们直接影响 RAG 系统的性能。
- チャンクサイズ:指每个块包含的文本量。分块大小过小,可能导致语义信息不完整;分块大小过大,则可能引入噪声,降低检索精度。
- 重叠大小:指相邻块之间重叠的文本量。重叠的目的是为了保证上下文的连续性,避免在块边界处丢失信息。
如何选择合适的分块大小和重叠?
选择合适的分块大小和重叠,没有绝对最优的答案,通常需要根据文档特性歌で応える实验效果来确定。以下是一些经验法则和建议:
- 启发式方法::
- 基于句子/段落长度: 可以分析文档的平均句子长度或段落长度,作为分块大小的参考。例如,如果平均段落长度为 150 个 Token,可以尝试将块大小设置为 150-200 个 Token。
- 考虑 LLM 的上下文窗口: 块大小不宜过大,避免超出 LLM 的上下文窗口限制。同时,也不宜过小,保证块包含足够的语义信息。
- 实验和评估:
- 迭代调优: 先设定一组初始的分块大小和重叠参数 (例如,块大小 500 Token,重叠 50 Token),构建 RAG 系统并进行评估。然后,逐步调整参数,观察检索和问答效果的变化,选择最优的参数组合。
- 指標の評価: 使用合适的评估指标来量化 RAG 系统的性能,例如检索的召回率 (Recall@k)、准确率 (Precision@k)、NDCG (Normalized Discounted Cumulative Gain) 等。这些指标可以帮助你客观地评估不同分块策略的效果。
Langchain 中的评估工具
Langchain 提供了一些评估工具,可以辅助 RAG 系统的评估和参数调优。例如,DatasetEvaluator 和 RetrievalQAChain 等工具,可以帮助你自动化地评估不同分块策略、检索模型和 LLM 模型的组合效果。
评估分块策略的效果
选择合适的分块策略后,如何评估其效果呢? “更好的分块意味着更好的检索”,但如何量化 “更好” 呢? 我们需要一些指标来评估分块策略的优劣。
以下是一些常用的评估指标,可以帮助你评估分块策略对 RAG 系统性能的影响:
- 指標の検索:
- 召回率 (Recall@k): 指在 Top-k 个检索结果中,相关文档 (或块) 的比例。召回率越高,表示分块策略越能将相关信息检索出来。
- 准确率 (Precision@k): 指在 Top-k 个检索结果中,真正相关的文档 (或块) 的比例。准确率越高,表示检索结果的质量越高。
- NDCG (Normalized Discounted Cumulative Gain): 是一种更精细的排序质量评估指标,考虑了检索结果的相关性等级和位置。NDCG 越高,表示检索排序质量越好。
- 问答指标:
- 答案相关性 (Answer Relevance): 评估 LLM 生成的答案与问题的相关程度。答案相关性越高,表示 RAG 系统越能根据检索到的信息生成有意义的答案。
- 答案准确性 (Answer Accuracy/Faithfulness): 评估 LLM 生成的答案是否忠实于检索到的上下文信息,避免 “幻觉” 和不实信息。答案准确性越高,表示分块策略越能提供可靠的上下文,引导 LLM 生成更可信的答案。
- 答案流畅性和连贯性 (Answer Fluency and Coherence): 虽然主要受 LLM 自身能力影响,但好的分块策略也能间接提升答案的流畅性和连贯性。例如,语义分块能提供更连贯的上下文,有助于 LLM 生成更自然的语言。
- 答案相关性 (Answer Relevance): 评估 LLM 生成的答案与问题的相关程度。答案相关性越高,表示 RAG 系统越能根据检索到的信息生成有意义的答案。
评估工具和方法
- 手動評価: 最直接、最可靠的方法。邀请人工评估者,根据预设的评估标准,对 RAG 系统的检索结果和问答答案进行评分。人工评估的缺点是成本高、耗时,且主观性较强。
- 自动化评估: 使用自动化评估指标和工具,例如:
- 指標の検索: 如前述的召回率、准确率、NDCG 等,可以使用标准的信息检索评测工具 (如
rank_bm25
,センテンス・トランスフォーマー
等) 进行自动化计算。 - 问答指标: 可以使用一些 NLP 评估指标 (如 BLEU, ROUGE, METEOR, BERTScore 等) 来辅助评估答案质量。但需要注意,自动化问答评估指标目前仍存在局限性,不能完全替代人工评估。
- Langchain 评估工具: Langchain 提供了一些集成的评估工具,例如
DatasetEvaluator
歌で応えるRetrievalQAChain
,可以简化 RAG 系统的自动化评估流程。
- 指標の検索: 如前述的召回率、准确率、NDCG 等,可以使用标准的信息检索评测工具 (如
评估流程建议
- 构建评估数据集: 准备一组包含问题和对应标准答案的评估数据集。数据集应尽可能覆盖 RAG 系统的典型应用场景和问题类型。
- 选择评估指标: 根据评估目的,选择合适的检索指标和问答指标。可以同时使用人工评估和自动化评估相结合的方式。
- 运行 RAG 系统: 使用不同的分块策略、检索模型和 LLM 模型组合,在评估数据集上运行 RAG 系统,记录评估结果。
- 分析和比较: 对比不同策略的评估指标,分析其优缺点,并选择最优的策略组合。
- 反復最適化: 根据评估结果,不断调整分块策略、检索模型和 LLM 模型的参数,进行迭代优化,提升 RAG 系统性能。
总结:选择最适合你的分块策略
本文深入探讨了 RAG 应用中至关重要的文档分块技术,从基础的递归字符分割,到更智能的语义分块和 Agentic 分块,再到针对不同文档格式的精细化分块策略,以及分块大小、重叠参数的选择和评估方法,进行了全面的介绍。
核心要点回顾
- 分块质量决定 RAG 质量: 好的分块策略是构建高性能 RAG 系统的基石。
- 没有万能的分块策略: 不同的分块策略各有优缺点,适用于不同的场景。你需要根据项目的具体需求、文档特性和资源限制,选择最合适的策略。
- 递归字符分割: 简单、快速、经济,适合原型开发和对成本敏感的项目。
- 语义分块: 理解文本语义,生成语义连贯的块,提高检索质量,但计算成本较高。
- Agentic 分块: 模拟人类理解,更智能、更灵活,能处理复杂文档,但成本高昂,Prompt 工程复杂。
- 针对不同格式的分块: 针对 Markdown, HTML, PDF, 代码等不同格式,需要采用精细化的分块策略,利用文档的结构信息。
- 分块大小和重叠: 需要根据文档特性和实验效果进行调优,没有绝对最优值。
- 评估是关键: 通过评估指标和人工评估,量化分块策略的效果,并进行迭代优化。
如何选择?我的建议
- 快速原型验证: 优先尝试递归字符分割,快速搭建 RAG 原型,验证系统可行性。
- 追求更高质量: 如果对 RAG 质量有较高要求,且计算资源允许,可以尝试语义分块.
- 处理复杂文档: 对于结构复杂、语义跳跃性强的文档,Agentic 分块可能是更优选择,但需要仔细权衡成本和效果。
- 针对特定格式: 如果处理的是 Markdown, HTML, PDF, 代码等特定格式的文档,务必采用针对性的分块策略,充分利用文档的结构信息。
- 持续迭代优化: 分块策略的选择不是一蹴而就的。在实际项目中,需要不断实验、评估和迭代优化,才能找到最适合你的分块方案。
希望本文能帮助你更深入地理解 RAG 分块技术,并在实际项目中选择和应用合适的分块策略,构建更强大的 RAG 应用!