RAG(Retrieval-Augmented Generation,检索增强生成)系统的效果不只取决于大语言模型和 Prompt。很多问答质量问题,比如上下文缺失、引用不完整、答案凭空补全,根源往往出现在文档入库之前:知识被切碎的方式不对。
一个典型 RAG 流程大致如下:
flowchart LR
A[原始文档] --> B[清洗与解析]
B --> C[文档分块 Chunking]
C --> D[Embedding 向量化]
D --> E[(向量数据库)]
F[用户问题] --> G[问题向量化]
G --> H[向量检索]
E --> H
H --> I[重排与上下文组装]
I --> J[LLM 生成答案]
Chunking 的位置在 Embedding(向量嵌入)之前。也就是说,后面的向量检索、重排、上下文拼接,都是建立在“块”这个基本单位上的。如果块切得太粗,检索结果里会夹杂大量无关信息;如果块切得太细,模型拿到的内容又可能缺少定义、条件、例子和上下文。
合理的分块策略要解决两个问题:
- 让检索更准:用户问题能命中真正相关的知识片段。
- 让生成更稳:被送入大语言模型的上下文足够完整,可以支撑答案。
1. Chunking 到底在切什么
Chunking 指的是把一篇长文档拆成多个较小文本块,每个块会被单独向量化,并作为检索系统中的基本召回单元。
分块里最常见的两个参数是:
chunk_size:单个块的目标大小。chunk_overlap:相邻块之间保留的重叠内容。
图中可以看到,相邻文本块并不是完全首尾相接,而是保留了一段重复区域。这个重复区域就是 chunk_overlap。它的作用是缓解边界切断问题,比如一句定义在前一个块末尾,解释在后一个块开头,如果没有重叠,检索时很可能只拿到其中一半。
一个块的大小不是越大越好,也不是越小越好。它本质上是在两个目标之间做平衡:
| 参数 | 太小的问题 | 太大的问题 | 合理目标 |
|---|---|---|---|
chunk_size | 上下文不足,命中关键词但无法支撑答案 | 噪声多,相似度被稀释,占用上下文窗口 | 覆盖一个完整语义单元 |
chunk_overlap | 边界处信息容易断裂 | 索引变大,重复召回增多,重排压力上升 | 保留必要跨块线索 |
在中文知识库里,可以先用下面的范围做基线:
| 文档类型 | chunk_size 建议 | chunk_overlap 建议 |
|---|---|---|
| 普通说明文档 | 400~800 中文字符 | 10%~20% |
| 技术手册、长句较多文档 | 700~1000 中文字符 | 10%~15% |
| FAQ、公告、法规条款 | 300~700 中文字符 | 句子级重叠或 10%左右 |
| 对话、会议纪要 | 6~12 轮对话 | 1~2 轮对话 |
重叠比例通常不建议超过 25%。超过这个范围后,索引体积、检索重复率和重排成本都会明显增加,但答案质量不一定继续提升。
2. 为什么 RAG 必须认真做分块
2.1 大语言模型有上下文窗口限制
LLM(Large Language Model,大语言模型)不能无限接收文本。即使使用长上下文模型,把整篇文档直接塞进去也会带来三个问题:
- 成本高,输入 token 越多,调用成本越高。
- 信息密度低,真正相关的内容被大量无关文本淹没。
- 长上下文里模型未必总能稳定抓住关键位置。
所以 RAG 通常不会把整篇文档交给模型,而是先检索出最相关的几个块,再拼成上下文。
2.2 分块粒度影响检索信噪比
向量检索比较的是问题向量和文本块向量之间的相似度。块太大时,一个块里可能同时包含安装、配置、错误码、权限说明等多种内容,向量会变得“平均”,相关信号被稀释。
块太小时,又会出现另一类问题:检索命中了一句话,但这句话缺少前提条件。例如:
可以通过该参数开启缓存。
如果没有上一句,模型根本不知道“该参数”是什么,也不知道缓存作用于哪个模块。
2.3 分块边界影响事实完整性
很多知识不是孤立句子,而是由定义、条件、步骤、示例共同组成的结构:
当 enable_cache=true 时,系统会读取本地缓存。
如果缓存不存在,会回退到远程服务。
如果这两句被切到不同块里,用户问“缓存不存在时怎么办”,检索可能只命中第一句,答案就容易漏掉回退逻辑。
理想的分块应该尽量贴近文档的自然边界:
- 标题
- 段落
- 列表
- 表格
- 代码块
- 问答对
- 对话轮次
- 语义话题转折点
3. 基础分块策略
基础分块实现成本低,适合建立第一版 RAG 系统的基线。它不一定最优,但能帮助快速跑通入库、检索和评估链路。
3.1 固定长度分块
固定长度分块按照预设字符数直接切文本,不理解标题、段落和语义。
from langchain_text_splitters import CharacterTextSplitter
splitter = CharacterTextSplitter(
separator="", # 纯字符切分
chunk_size=600,
chunk_overlap=90,
)
chunks = splitter.split_text(text)
这种方式的优势是简单、快、适配任意文本。缺点也明显:它可能把一句话、一个表格、一段代码切成两半。
| 维度 | 固定长度分块 |
|---|---|
| 实现成本 | 很低 |
| 适合场景 | 结构弱、质量要求不高的纯文本基线 |
| 主要风险 | 破坏语义边界 |
| 调参重点 | 控制块长分布和重叠比例 |
中文语料可以从 chunk_size=300~800 开始试。如果 Embedding 模型推荐输入长度是 512 或 1024 tokens,可以粗略折算成 350 或 700 左右中文字符作为起点,但最终仍要靠验证集评估。
3.2 句子分块
句子分块先把文本切成句子,再把若干句子聚合成接近目标长度的块。它比固定长度分块更尊重语义完整性。
适合场景包括:
- 法律法规
- 新闻公告
- FAQ
- 产品说明
- 以自然语言句子为主的知识库
中文分句不能直接套英文分句器。中文句末标点包括 。!?;,还要处理引号、省略号等边界。简单场景可以先用正则实现:
import re
from typing import List
def split_sentences_zh(text: str) -> List[str]:
pattern = re.compile(r'([^。!?;]*[。!?;]+|[^。!?;]+$)')
return [
m.group(0).strip()
for m in pattern.finditer(text)
if m.group(0).strip()
]
def sentence_chunk(text: str, chunk_size: int = 600, overlap: int = 80) -> List[str]:
sentences = split_sentences_zh(text)
chunks = []
buf = ""
for sentence in sentences:
if len(buf) + len(sentence) <= chunk_size:
buf += sentence
else:
if buf:
chunks.append(buf)
tail = buf[-overlap:] if overlap > 0 and len(buf) > overlap else ""
buf = tail + sentence
if buf:
chunks.append(buf)
return chunks
chunks = sentence_chunk(text, chunk_size=600, overlap=90)
如果文档质量复杂,比如存在大量中英文混排、括号、项目符号和不规范标点,可以换成中文 NLP(Natural Language Processing,自然语言处理)工具做更稳健的分句,例如 HanLP、Stanza 或 spaCy 中文生态。
句子分块的常见问题是块过短。解决办法不是直接放弃句子边界,而是把多个句子合并到目标长度附近,并设置最小块长:
MIN_CHUNK_CHARS = 300
TARGET_CHUNK_CHARS = 700
3.3 递归字符分块
递归字符分块会按照一组“从粗到细”的分隔符逐层尝试切分。它会优先按标题、段落、换行切;如果仍然超长,再退到空格甚至字符级。
from langchain_text_splitters import RecursiveCharacterTextSplitter
separators = [
r"\n#{1,6}\s", # Markdown 标题
r"\n\d+(?:\.\d+)*\s", # 编号标题,例如 1. / 2.3.
"\n\n", # 段落
"\n", # 行
" ", # 空格
"", # 兜底字符级
]
splitter = RecursiveCharacterTextSplitter(
separators=separators,
chunk_size=700,
chunk_overlap=100,
is_separator_regex=True,
)
chunks = splitter.split_text(text)
递归分块是很多 RAG 系统的默认选择,因为它在实现复杂度和效果之间比较均衡。
| 优点 | 缺点 |
|---|---|
| 能保留大部分自然边界 | 对表格、代码等强结构内容不够精细 |
| 参数少,容易作为基线 | 分隔符顺序不合理时会导致块长不稳定 |
| 适配说明文档、知识库条目、报告 | 无法理解真正的语义话题变化 |
中文文档建议:
chunk_size = 400 # 到 800 之间试验
chunk_overlap = 80 # 约 10%~20%
如果块长分布出现长尾,说明粗粒度边界不够,需要增加标题、编号、特殊分隔符。如果块普遍太短,说明分隔符太敏感,可以降低某些分隔符优先级,或者在切分后做短块合并。
4. 结构感知分块
结构感知分块不再只看字符长度,而是利用文档已有结构来决定边界。对于 Markdown、HTML、PDF 解析后的文档、技术手册、接口文档,这类策略通常比基础分块更可靠。
核心思想是:
flowchart TD
A[解析文档结构] --> B[识别标题、段落、列表、表格、代码块]
B --> C[按章节生成父级结构块]
C --> D{结构块是否超长}
D -- 否 --> E[直接作为候选块]
D -- 是 --> F[按子标题、段落、句子二次切分]
E --> G[合并过短块]
F --> G
G --> H[写入标题路径和来源 metadata]
4.1 Markdown 结构分块
Markdown 文档天然包含标题层级,分块时应该保留标题路径。例如,一个块来自:
RAG 指南 > 检索优化 > 重排策略
那么这条路径应该写入块文本前缀或 metadata。这样做有两个好处:
- 检索时能利用章节语义。
- 生成答案时可以追溯来源位置。
下面是一个简化版 Markdown 结构分块器:
import re
from typing import Dict, List
HEADING_PATTERN = re.compile(r'^(#{1,6})\s+(.*)$')
FENCE_PATTERN = re.compile(r'^```')
def split_markdown_structure(
text: str,
chunk_size: int = 900,
min_chunk: int = 250,
) -> List[Dict]:
lines = text.splitlines()
sections = []
in_code = False
path_stack = []
current = {
"level": 0,
"title": "",
"content": [],
"path": [],
}
for line in lines:
if FENCE_PATTERN.match(line):
in_code = not in_code
match = HEADING_PATTERN.match(line) if not in_code else None
if match:
if current["content"]:
sections.append(current)
level = len(match.group(1))
title = match.group(2).strip()
while path_stack and path_stack[-1][0] >= level:
path_stack.pop()
path_stack.append((level, title))
breadcrumbs = [title for _, title in path_stack]
current = {
"level": level,
"title": title,
"content": [],
"path": breadcrumbs,
}
else:
current["content"].append(line)
if current["content"]:
sections.append(current)
chunks = []
def emit_chunk(text_block: str, section: Dict):
clean_text = text_block.strip()
if not clean_text:
return
breadcrumbs = section["path"]
prefix = " > ".join(breadcrumbs[-3:])
chunk_text = f"[{prefix}]\n{clean_text}" if prefix else clean_text
chunks.append({
"text": chunk_text,
"meta": {
"section_title": breadcrumbs[-1] if breadcrumbs else "",
"breadcrumbs": breadcrumbs,
"section_level": section["level"],
}
})
for section in sections:
raw = "\n".join(section["content"]).strip()
if not raw:
continue
if len(raw) <= chunk_size:
emit_chunk(raw, section)
continue
paragraphs = [p.strip() for p in raw.split("\n\n") if p.strip()]
buf = ""
for paragraph in paragraphs:
if len(buf) + len(paragraph) + 2 <= chunk_size:
buf = f"{buf}\n\n{paragraph}" if buf else paragraph
else:
if buf:
emit_chunk(buf, section)
buf = paragraph
if buf:
emit_chunk(buf, section)
merged = []
for chunk in chunks:
if not merged:
merged.append(chunk)
continue
same_section = (
merged[-1]["meta"]["breadcrumbs"] == chunk["meta"]["breadcrumbs"]
)
too_short = len(chunk["text"]) < min_chunk
if same_section and too_short:
merged[-1]["text"] += "\n\n" + chunk["text"]
else:
merged.append(chunk)
return merged
结构分块时要把代码块、表格、公式当成原子单元。代码和注释被切开后,检索命中一半代码很难支撑正确答案;表头和数据分离后,模型也无法判断字段含义。
4.2 HTML 和 PDF 的结构处理
HTML 可以通过 DOM(Document Object Model,文档对象模型)遍历标题、段落、列表、代码块和表格。PDF 更麻烦,因为它经常带有页眉、页脚、页码、水印和分栏,入库前应该先清洗噪声。
| 来源格式 | 解析重点 | 常见噪声 | 分块建议 |
|---|---|---|---|
| Markdown | 标题、列表、代码块 | 较少 | 按标题树切父块 |
| HTML | H1~H6、p、li、pre、table | 导航栏、版权、侧边栏 | DOM 解析后按语义节点切 |
| 段落、表格、页码、版面顺序 | 页眉页脚、水印、断行 | 先做版面清洗,再结构分块 | |
| Word | 标题样式、表格、批注 | 页眉页脚、修订痕迹 | 读取样式层级,保留表格原子性 |
结构感知分块的 metadata 很关键,至少建议保留:
{
"doc_id": "manual-001",
"breadcrumbs": ["用户指南", "安装", "Docker 部署"],
"section_title": "Docker 部署",
"block_type": "paragraph",
"start_offset": 1024,
"end_offset": 1840,
"source": "docs/install.md"
}
metadata 不只是为了展示来源,也可以用于过滤、重排、邻接扩展和去重。
4.3 对话式分块
客服记录、会议纪要、访谈和工单不能按普通段落切。对话的基本语义单元通常是“轮次”或“问答对”。
对话式分块应该满足两点:
- 不拆开明显的问答对。
- 重叠按轮次做,而不是按字符做。
from typing import Dict, List
def chunk_dialogue(
turns: List[Dict],
max_turns: int = 10,
max_chars: int = 900,
overlap_turns: int = 2,
) -> List[Dict]:
"""
turns 示例:
[
{"speaker": "User", "text": "订单为什么还没发货?", "ts_start": 10},
{"speaker": "Agent", "text": "我帮您查询一下物流状态。", "ts_start": 15}
]
"""
chunks = []
i = 0
while i < len(turns):
j = i
char_count = 0
speakers = set()
while j < len(turns):
turn = turns[j]
text_len = len(turn["text"])
if (j - i + 1) > max_turns or (char_count + text_len) > max_chars:
break
char_count += text_len
speakers.add(turn["speaker"])
j += 1
window = turns[i:j] if j > i else [turns[i]]
text = "\n".join(
f'{turn["speaker"]}: {turn["text"]}'
for turn in window
)
chunks.append({
"text": text,
"meta": {
"speakers": list(speakers),
"turns_range": (i, j - 1),
"ts_start": window[0].get("ts_start"),
"ts_end": window[-1].get("ts_end"),
}
})
if j >= len(turns):
break
next_start = i + len(window) - overlap_turns
i = max(next_start, i + 1)
return chunks
参数可以从下面的范围开始:
| 参数 | 建议值 |
|---|---|
max_turns | 6~12 轮 |
max_chars | 600~1000 字 |
overlap_turns | 1~2 轮 |
检索阶段还可以做邻接扩展:如果某个对话块被召回,可以把它前后各 1~2 轮一起拼进最终上下文,避免模型只看到回答而看不到问题。
5. 语义与主题分块
结构分块依赖文档格式。如果文档没有清晰标题,或者虽然有标题但内部话题变化复杂,就需要从语义上判断边界。
5.1 语义分块
语义分块的核心做法是:先按句子切分,再计算句子向量;当相邻句子或相邻窗口之间的语义相似度明显下降时,把它作为切分点。
flowchart LR
A[文本] --> B[中文分句]
B --> C[句子向量化]
C --> D[计算相邻语义变化]
D --> E{是否发生语义突变}
E -- 是 --> F[切分]
E -- 否 --> G[继续累积]
F --> H[长度约束与短块合并]
G --> H
一个可运行的简化实现如下:
import re
from typing import Dict, List
import numpy as np
from sentence_transformers import SentenceTransformer
def split_sentences_zh(text: str) -> List[str]:
pattern = re.compile(r'([^。!?;]*[。!?;]+|[^。!?;]+$)')
return [
m.group(0).strip()
for m in pattern.finditer(text)
if m.group(0).strip()
]
def semantic_chunk(
text: str,
model_name: str = "BAAI/bge-m3",
window_size: int = 2,
min_chars: int = 350,
max_chars: int = 1100,
lambda_std: float = 0.8,
) -> List[Dict]:
sentences = split_sentences_zh(text)
if not sentences:
return []
model = SentenceTransformer(model_name)
embeddings = model.encode(
sentences,
normalize_embeddings=True,
batch_size=64,
show_progress_bar=False,
)
embeddings = np.asarray(embeddings)
novelties = []
for i in range(len(sentences)):
if i == 0:
novelties.append(0.0)
continue
start = max(0, i - window_size)
ref = embeddings[start:i].mean(axis=0)
ref = ref / (np.linalg.norm(ref) + 1e-8)
novelty = 1.0 - float(np.dot(embeddings[i], ref))
novelties.append(novelty)
novelties = np.asarray(novelties)
threshold = float(novelties.mean() + lambda_std * novelties.std())
chunks = []
buf = ""
start_idx = 0
def flush(end_idx: int):
nonlocal buf, start_idx
if buf.strip():
chunks.append({
"text": buf.strip(),
"meta": {
"start_sent": start_idx,
"end_sent": end_idx - 1,
}
})
buf = ""
start_idx = end_idx
for i, sentence in enumerate(sentences):
over_max = len(buf) + len(sentence) > max_chars
can_split = len(buf) >= min_chars
if over_max and can_split:
flush(i)
buf += sentence
semantic_shift = novelties[i] > threshold
if can_split and semantic_shift:
flush(i + 1)
if buf:
flush(len(sentences))
return chunks
这里的 novelty 可以理解为“新句子和前面上下文有多不一样”。阈值越低,切得越碎;阈值越高,切得越保守。
中文技术文档可以从这些参数开始:
| 参数 | 建议 |
|---|---|
window_size | 2~4 句 |
min_chars | 300~400 |
max_chars | 1000~1200 |
lambda_std | 0.6~1.0 |
| 重叠 | 附加上一句,或 10%左右字符重叠 |
语义分块适合专题性强、论证结构明显的材料,比如白皮书、技术方案、研究报告和长 FAQ 聚合页。小文档不适合过度使用,因为句子数量太少时,相似度分布不稳定。
5.2 主题分块
主题分块关注更宏观的话题变化。它不是判断“这一句和上一句像不像”,而是判断一段时间内文本是否切换到了另一个主题。
常见流程是:
- 中文分句或分段。
- 生成句向量。
- 用聚类算法给句子打主题标签。
- 对标签序列做滑窗平滑,避免频繁抖动。
- 当主题稳定变化且满足最小长度时切分。
import re
from typing import Dict, List
import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.cluster import KMeans
def split_sentences_zh(text: str) -> List[str]:
pattern = re.compile(r'([^。!?;]*[。!?;]+|[^。!?;]+$)')
return [
m.group(0).strip()
for m in pattern.finditer(text)
if m.group(0).strip()
]
def topic_chunk(
text: str,
k_topics: int = 5,
min_chars: int = 500,
max_chars: int = 1400,
smooth_window: int = 2,
model_name: str = "BAAI/bge-m3",
) -> List[Dict]:
sentences = split_sentences_zh(text)
if not sentences:
return []
model = SentenceTransformer(model_name)
embeddings = model.encode(
sentences,
normalize_embeddings=True,
batch_size=64,
show_progress_bar=False,
)
embeddings = np.asarray(embeddings)
n_clusters = min(k_topics, len(sentences))
kmeans = KMeans(n_clusters=n_clusters, n_init="auto", random_state=42)
labels = kmeans.fit_predict(embeddings)
smoothed = labels.copy()
for i in range(len(labels)):
start = max(0, i - smooth_window)
end = min(len(labels), i + smooth_window + 1)
values, counts = np.unique(labels[start:end], return_counts=True)
smoothed[i] = int(values[np.argmax(counts)])
chunks = []
buf = ""
start_idx = 0
current_label = smoothed[0]
def flush(end_idx: int):
nonlocal buf, start_idx
if buf.strip():
chunks.append({
"text": buf.strip(),
"meta": {
"start_sent": start_idx,
"end_sent": end_idx - 1,
"topic": int(current_label),
}
})
buf = ""
start_idx = end_idx
for i, sentence in enumerate(sentences):
switched = smoothed[i] != current_label
under_min = len(buf) < min_chars
over_max = len(buf) + len(sentence) > max_chars
if switched and not under_min:
flush(i)
current_label = smoothed[i]
if over_max and not under_min:
flush(i)
buf += sentence
if buf:
flush(len(sentences))
return chunks
主题数 k_topics 很难一次设准。可以用轮廓系数(silhouette score)或肘部法做初筛,再结合领域知识调整。对于一篇只有几段的小文档,主题分块通常不如结构分块或语义分块稳定。
6. 高级分块:小块召回,大块回答
基础策略和结构策略解决的是“怎么切”。高级策略更关注“怎么检索和组装上下文”。
6.1 小-大分块
小-大分块的思路很直接:
- 用小块做检索,因为小块更容易精准命中问题。
- 用大块做回答上下文,因为大块保留了定义、解释、例子和上下游条件。
sequenceDiagram
participant Q as 用户问题
participant S as 小块向量索引
participant P as 父块存储
participant R as 重排器
participant L as 大语言模型
Q->>S: 检索 top_k 小块
S-->>Q: 返回命中句子或短段
Q->>P: 按 parent_id 找到父块
P-->>Q: 返回段落/小节上下文
Q->>R: 对父块候选重排
R-->>Q: 返回排序后的上下文
Q->>L: 组装上下文并生成答案
伪代码如下:
# 离线阶段:
# 1. 建小块索引:句子、短段、子句
# 2. 存父块内容:段落、小节、章节
# 3. 保存 child_id -> parent_id 映射
small_hits = small_index.search(embed(query), top_k=30)
groups = group_by_parent_id(small_hits)
scored_parents = score_groups(
groups,
strategy="max_mean_coverage",
)
candidate_parent_ids = top_m(scored_parents, m=3)
rerank_inputs = [
(query, parent_store[parent_id]["text"])
for parent_id in candidate_parent_ids
]
reranked_parent_ids = cross_encoder_rerank(rerank_inputs)
contexts = []
for parent_id in reranked_parent_ids:
hit_children = groups[parent_id]
context = build_local_window(
parent_text=parent_store[parent_id]["text"],
hit_children=hit_children,
window_sents=1,
)
contexts.append(context)
final_context = pack_under_budget(contexts, token_budget=3000)
这种方式比“只检索大块”更精准,也比“只把小块送给模型”更完整。
6.2 父子段分块
父子段分块是小-大分块的工程化建模方式。它显式维护父块和子块之间的关系。
flowchart TD
A[文档] --> B[父块:章节 / 段落 / 小节]
B --> C[子块:句子 / 短段 / 子句]
C --> D[(子块向量索引)]
B --> E[(父块原文存储)]
F[查询] --> D
D --> G[召回子块]
G --> H[按 parent_id 聚合]
H --> E
E --> I[取命中窗口和标题路径]
I --> J[重排与上下文拼接]
父子段分块适合这些场景:
| 场景 | 为什么适合 |
|---|---|
| 技术手册 | 用户问题往往命中某句配置说明,但回答需要完整小节 |
| 法规条款 | 证据需要句级准确,解释需要条款上下文 |
| FAQ 聚合页 | 问题匹配适合小块,答案生成需要问答对完整 |
| 白皮书 | 长段落中只有局部相关,需要抽取命中窗口 |
父块和子块建议保存这些字段:
{
"parent": {
"parent_id": "doc-001#section-03",
"text": "完整小节文本",
"breadcrumbs": ["部署指南", "缓存配置"],
"start_offset": 1200,
"end_offset": 2600
},
"child": {
"child_id": "doc-001#section-03#sent-05",
"parent_id": "doc-001#section-03",
"text": "当 enable_cache=true 时,系统会优先读取本地缓存。",
"start": 410,
"end": 460
}
}
父块聚合打分可以用:
score_parent =
α * max(child_scores)
+ (1 - α) * mean(child_scores)
+ β * coverage
其中:
max(child_scores)表示父块里最强命中。mean(child_scores)表示整体相关性。coverage表示命中的子块覆盖程度。
还要注意长父块偏置。父块越长,子块越多,天然更容易被命中。可以加入长度归一化:
density = sum(exp(score_i)) / len(parent_text)
6.3 Agent 式分块
Agent 式分块是让一个低温度、强约束的大语言模型参与边界判断。它适合高度复杂的混合文档,比如一份材料里同时有说明文字、代码、表格、公式和长篇叙述,普通规则难以切出稳定边界。
关键不是让模型自由发挥,而是要求它输出结构化边界:
系统指令:
你是一个 RAG 分块器。目标是创建高内聚、可追溯的文本块。
规则:
1. 不得在代码块、表格、公式中间切分。
2. 每块长度控制在 400~1000 字。
3. 保留标题路径。
4. 尽量让“定义 + 解释 + 示例”留在同一块。
5. 只输出 JSON。
输出格式:
{
"segments": [
{
"start": 0,
"end": 812,
"title_path": ["指南", "安装"],
"reason": "完整安装步骤和注意事项"
}
]
}
Agent 输出后必须经过校验器:
| 校验项 | 目的 |
|---|---|
| 起止 offset 是否递增 | 防止边界错乱 |
| 是否越界 | 防止截取非法文本 |
| 是否切开代码块、表格、公式 | 保持原子结构 |
| 是否满足最小/最大长度 | 控制块粒度 |
| 是否覆盖全文 | 防止遗漏内容 |
如果校验失败,应该自动回退到递归分块或结构分块。Agent 式分块成本较高,适合只处理疑难块,而不是对所有文档全量调用。
7. 混合分块:更适合生产环境的方案
单一策略很难覆盖所有文档。生产环境里更常见的是混合分块:先按结构粗切,再对异常块使用更细策略,检索时配合父子段或小-大上下文组装。
可以把策略选择写成一套调度规则:
flowchart TD
A[原始文档] --> B[清洗与结构解析]
B --> C{块类型}
C -- 代码/表格/公式 --> D[作为原子块保留]
C -- 对话 --> E[按说话人与轮次切分]
C -- 普通文本 --> F{是否超长或主题混杂}
F -- 否 --> G[递归字符分块]
F -- 是 --> H[语义分块或主题分块]
D --> I[写入 metadata]
E --> I
G --> I
H --> I
I --> J[子块索引 + 父块存储]
一个简化版混合调度器如下:
from typing import Callable, Dict, List
def hybrid_chunk(
doc_text: str,
parse_structure: Callable,
recursive_splitter: Callable,
semantic_splitter: Callable,
dialogue_splitter: Callable,
max_coarse_len: int = 1100,
min_chunk_len: int = 320,
) -> List[Dict]:
"""
parse_structure 返回示例:
[
{
"type": "text|code|table|formula|dialogue",
"text": "...",
"breadcrumbs": ["指南", "安装"],
"anchor": "install"
}
]
"""
blocks = parse_structure(doc_text)
chunks = []
def emit(text: str, meta: Dict):
clean_text = text.strip()
if not clean_text:
return
breadcrumbs = meta.get("breadcrumbs", [])
prefix = " > ".join(breadcrumbs[-3:])
final_text = f"[{prefix}]\n{clean_text}" if prefix else clean_text
chunks.append({
"text": final_text,
"meta": meta,
})
for block in blocks:
block_type = block.get("type", "text")
text = block.get("text", "")
if block_type in {"code", "table", "formula"}:
emit(text, {**block, "splitter": "atomic"})
continue
if block_type == "dialogue":
for chunk in dialogue_splitter(block.get("turns", [])):
emit(chunk["text"], {**block, "splitter": "dialogue"})
continue
if len(text) <= max_coarse_len:
sub_chunks = recursive_splitter(text)
buf = ""
for sub_chunk in sub_chunks:
sub_text = sub_chunk["text"]
if len(buf) + len(sub_text) < min_chunk_len:
buf += sub_text
else:
emit(buf or sub_text, {**block, "splitter": "recursive"})
buf = ""
if buf:
emit(buf, {**block, "splitter": "recursive"})
else:
for chunk in semantic_splitter(text):
emit(chunk["text"], {**block, "splitter": "semantic"})
return chunks
生产环境可以按质量和成本分成三个档位:
| 档位 | 分块策略 | 检索策略 | 适合场景 |
|---|---|---|---|
| fast | 结构分块 + 递归分块 | 普通向量检索 | 快速上线、成本敏感 |
| balanced | 结构分块 + 异常块语义分块 | 小-大检索 + 轻量重排 | 大多数知识库 |
| quality | balanced + Agent 精修疑难块 | 父子段聚合 + 交叉编码重排 | 高准确率问答、法规、技术支持 |
8. 分块策略怎么选
不同文档适合不同切法。可以直接用这张表做初始选择:
| 文档类型 | 推荐策略 | 不建议 |
|---|---|---|
| 普通知识库文章 | 递归分块、结构分块 | 纯固定长度硬切 |
| Markdown 技术文档 | 结构分块 + 递归二次切分 | 忽略标题路径 |
| HTML 页面 | DOM 解析 + 结构分块 | 把导航栏、页脚一起入库 |
| PDF 报告 | 清洗版面噪声 + 结构/语义分块 | 直接按页切 |
| 法规、公告 | 句子分块 + 条款结构 metadata | 把条款切断 |
| 代码文档 | 代码块原子保留 + 解释文字打包 | 切开函数和注释 |
| 表格文档 | 表头与数据同行保留 | 表头和表体分离 |
| 客服对话 | 对话轮次分块 | 字符级切分 |
| 长篇多主题报告 | 结构分块 + 语义/主题分块 | 只设一个固定长度 |
经验规则可以压缩成几句话:
- 有结构,先用结构。
- 没结构但语义清晰,用句子或递归。
- 话题转折明显,用语义分块。
- 文档很长且多主题,再考虑主题分块。
- 答案需要完整上下文,用小-大或父子段检索。
- 代码、表格、公式不要从中间切开。
9. 调参与评估方法
分块调优不要和检索算法、Embedding 模型、重排器一起乱调。更稳的方式是固定其他组件,只改变分块参数。
9.1 建验证集
验证集至少包含:
[
{
"question": "如何开启本地缓存?",
"gold_doc_id": "cache-guide",
"gold_answer_spans": [
"当 enable_cache=true 时,系统会优先读取本地缓存。"
]
}
]
每个问题最好标注:
- 正确文档 ID
- 正确章节
- 正确答案依据片段
- 期望答案要点
9.2 看检索指标
| 指标 | 含义 |
|---|---|
| Recall@k | 前 k 个结果是否召回了正确依据 |
| MRR(平均倒数排名) | 正确结果排得越靠前分数越高 |
| nDCG(归一化折损累计增益) | 同时考虑相关性和排序位置 |
| 来源命中文档覆盖率 | 是否命中正确来源文档 |
| 重复召回率 | top_k 里是否充满相似重复块 |
| Faithfulness(事实一致性) | 答案是否被检索上下文支撑 |
9.3 观察块长分布
除了指标,还要看块长分布:
lengths = [len(chunk["text"]) for chunk in chunks]
print("chunk count:", len(lengths))
print("min:", min(lengths))
print("max:", max(lengths))
print("avg:", sum(lengths) / len(lengths))
可以进一步画分布图:
import matplotlib.pyplot as plt
plt.hist(lengths, bins=30)
plt.xlabel("chunk length")
plt.ylabel("count")
plt.title("Chunk Length Distribution")
plt.show()
常见现象和处理方式:
| 现象 | 可能原因 | 调整方式 |
|---|---|---|
| 长尾块很多 | 标题/段落分隔符不足 | 增加粗粒度分隔符,降低 chunk_size |
| 块普遍很短 | 分隔符太细,缺少短块合并 | 增加 min_chunk,合并同章节短块 |
| 命中很多无关内容 | 块太大,噪声多 | 降低 chunk_size,加强结构边界 |
| 答案经常缺上下文 | 块太小或边界切断 | 增加 overlap,使用父子段检索 |
| top_k 重复严重 | overlap 太大 | 降低 overlap,引入去重 |
| 引用定位差 | metadata 不完整 | 增加 breadcrumbs、offset、source |
10. 实战建议
RAG 分块可以按这样的顺序落地:
flowchart TD
A[建立固定检索链路] --> B[用递归分块做基线]
B --> C[构造问答验证集]
C --> D[记录 Recall@k / MRR / nDCG]
D --> E{问题主要是什么}
E -- 噪声多 --> F[减小块长或增强结构边界]
E -- 上下文断裂 --> G[增加重叠或启用父子段]
E -- 结构丢失 --> H[加入标题路径 metadata]
E -- 话题混杂 --> I[对异常块做语义分块]
F --> J[重新评估]
G --> J
H --> J
I --> J
更稳的生产配置通常是:
- 文档先清洗,去掉页眉、页脚、导航、重复版权信息。
- 能解析结构就先解析结构,标题路径写入 metadata。
- 普通文本用递归分块作为默认策略。
- 超长、主题混杂的块再用语义分块处理。
- 代码、表格、公式作为原子块,和解释文字尽量打包。
- 检索侧使用小块召回,大块或局部父块窗口作为上下文。
- 用验证集持续评估,而不是只凭肉眼看几个问答样例。
分块不是一次性参数配置,而是 RAG 系统里需要持续调优的索引设计问题。切得好,Embedding 才能表达正确语义;召回准,重排和大语言模型才有可靠输入。把知识按自然结构和语义边界交给模型,通常比盲目更换向量模型更能稳定改善问答质量。
