详细解释
Semantic Chunking(语义切片,也叫 Semantic Splitting / Intelligent Chunking) 是 RAG 领域里”投入产出比最高的优化项”——大多数团队从”固定 512 Token 一刀切”切换到语义切片后,Recall@5 直接涨 15–30%,不用改模型不用换向量库,只换切分逻辑就行。
传统固定长度切片的问题:用 RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50) 硬切,很容易把:
- 一个完整的”问题+答案”拦腰切成两段;
- 表格的表头和表体分开;
- 法律合同的”条款 3.2”标题和正文分家;
- 代码块的函数头和函数体切断。
最后 Embedding 出来的向量是”半句话”,检索时相似度当然不高。
语义切片的核心原则是——在”语义边界”上切分,而不是在字符数 N 上切分。常见的语义边界(按优先级):
- Markdown / HTML 的层级标题(
# 一级 → ## 二级 → ### 三级) - 段落分隔(空行
\n\n) - 句子分隔(句号、问号、感叹号,中文
。!?/ 英文.?!) - 代码块边界(
```) - 表格边界(
<table>/ Markdown Table 表头) - 页面/章节/页眉页脚(PDF 切分尤其重要)
进阶版语义切片还会利用 Embedding 相似度本身做判断:
“按句子切出候选边界,计算相邻两段的 Embedding 余弦相似度。相似度低于阈值 0.7 时——说明话题变了,就在这里切一刀;高于 0.9 时——说明是同一个话题,继续合并。“(这种叫 Embedding-aware Chunking / LlamaIndex 的 SemanticChunker)
不同切片策略的效果对比(真实业务 RAG 召回率基准)
| 切片策略 | Recall@5 准确率 | 说明 |
|---|---|---|
| 固定 1024 Token,无重叠 | 52% | 最差,“一句话被切断”家常便饭 |
| 固定 512 Token,50 Token 重叠(LangChain 默认) | 63% | 及格线,新手默认值 |
| 先按 Markdown 标题分层,再按段落切(基础语义切片) | 78% | 免费的 +15%,代码 10 行 |
| 基础语义切片 + Embedding 相似度阈值合并 | 84% | 多一次 Embedding 调用 |
| 基础语义切片 + 文档结构识别(表格/代码/列表单独切) | 86% | 代码量翻倍,对专业文档收益大 |
| 语义切片 + HyDE + Rerank 三件套 | 93%+ | 推荐生产标配 |
实现一份”实用主义”语义切片器(伪代码 30 行)
# 依赖:markdown-it-py 解析 markdown 结构
from markdown_it import MarkdownIt
from sentence_transformers import SentenceTransformer # 或调 [Embedding API](/support/glossary/embedding/)
import numpy as np
def semantic_split(markdown_text: str, max_tokens=512, sim_threshold=0.70, embed_model=None):
"""先按 markdown 结构切候选块,再按 embedding 相似度合并。"""
md = MarkdownIt().enable("table").enable("strikethrough")
tokens = md.parse(markdown_text)
# 第 1 步:按标题/段落/代码块/表格 切"原子候选块"
chunks = [] # List[str]
cur, cur_level = "", None
for tok in tokens:
if tok.type in ("heading_open",):
if cur.strip(): chunks.append(cur.strip())
cur, cur_level = tok.markup + " ", "h" + tok.tag[1:]
elif tok.type in ("fence", "code_block"): # 代码块整体一块
if cur.strip(): chunks.append(cur.strip())
chunks.append("```" + tok.info + "\n" + tok.content + "```")
cur, cur_level = "", None
elif tok.type == "inline":
cur += tok.content
if cur.strip(): chunks.append(cur.strip())
# 第 2 步:相邻块的 Embedding 相似度高就合并
if embed_model is None:
embed_model = SentenceTransformer("BAAI/bge-m3") # 中文好用
vecs = embed_model.encode(chunks, normalize_embeddings=True)
merged, i, N = [chunks[0]], 1, len(chunks)
while i < N:
sim = float(np.dot(vecs[i - 1], vecs[i]))
if sim >= sim_threshold and token_count(merged[-1] + chunks[i]) < max_tokens:
merged[-1] = merged[-1] + "\n\n" + chunks[i]
else:
merged.append(chunks[i])
i += 1
return merged
切片的 4 条”不要”(避坑红宝书)
- 不要一刀切所有文档类型用同一套参数:产品 FAQ chunk_size=256(短 Q&A 要精)、合同/白皮书 chunk_size=1024 至 2048(上下文长要全)、API 文档按 endpoint 切天然语义完整。
- 不要丢元数据:每个 Chunk 存 DB 时带上
{source_file: xxx.pdf, page: 12, section: "条款3.2 付款", chunk_index: 17},否则最后 LLM 引用你找不到原文在哪。 - 不要切完就 Embedding,先做清洗:PDF 解析的页眉页脚/目录/图注噪音先去掉,HTML 的广告/侧边栏/ Cookie 提示语先剔除,垃圾切 1000 条不如干净切 100 条。
- 不要只看 Recall,要端到端 Answer Accuracy:有的切片策略 Recall@5 好看(召回来了一堆块),但都是碎片 LLM 拼不起来 → 最终回答准确率反而低。所以评测要端到端看 Answer F1 而不是只看 Recall。
唯元智创 的企业知识库服务已经集成了上表所有切片策略,控制台直接选”按 Markdown 切 / 语义切片 / 表格单独保护”三个复选框即可,不用写代码。
常见问题
Chunk 大小到底取多少?256?512?1024?
经验三段值:FAQ / 短问答 → 256 tokens(一个 Q&A 对刚好完整装下);一般文章/手册/博客 → 512 tokens(1 至 2 个段落,Embedding 质量和完整度平衡最好);长合同/论文/书籍章节 → 1024 至 2048 tokens(必须保留足够长的上下文关系)。再叠加 10 至 15% 的 chunk_overlap 防”一句话跨两个块”,基本覆盖 90% 业务。剩下 10% 的极端文档(代码库/超大表格/书籍全书)单独做定制化切分。
表格和代码块一切片就碎掉了怎么办?
两条铁律:(1)表格 / 代码块作为原子整体,max_tokens 以内永远不切;(2)超长表格/代码块做”总结备份”。实操:检测到一块是 Markdown 表格或
代码块,先数 token:如果 ≤ 1500 → 整块入向量库;如果 > 1500 → 除了原块切分外,再让 LLM 写一段”这段代码/表格主要在说什么”的自然语言总结,把总结 + 原块一起 Embedding 入库。检索时用户问题的自然语言通常和”总结块”匹配最好,拿到后再溯源到原代码/表格原文。Embedding 感知切片加一次 Embedding 会不会太贵?
Embedding 模型是所有 LLM API 里最便宜的一个数量级。OpenAI text-embedding-3-small 价格:每百万 tokens ¥0.02;bge-m3 开源本地跑:纯 GPU 算力,不按调用计费。10 万文档平均 1 篇 20 个候选块 = 200 万 tokens 切片合并前的 Embedding 计算 = 几块钱人民币。对比最终 RAG 回答准确率 +15% 带来的业务价值,切片阶段这点成本可以忽略不计。生产上推荐:切片阶段用便宜 / 开源的 Embedding 模型(bge-small、m3e-small、本地跑),最终入库时再切换到你主检索用的强模型。两次 Embedding 分开,成本几乎不涨。