B03「让 AI 读懂你的文档」文本分割与 Embedding 实战

0 阅读6分钟

首发于「林间昭语」 | 作者:程序员林间 | 阅读时间:约 8 分钟

关联阅读:前一篇《向量数据库怎么选》


一、最大的坑:你的文档"碎"得不对

很多人觉得 RAG 难,是难在"效果调优"。

但实际上,60% 的问题出在第一步:文本分割

你可能遇到过这种情况:

  • 问 AI "这份合同的关键条款是什么?"它答了一堆无关内容

  • 明明文档里有答案,但 AI 硬是说"没找到"

  • 检索出来了,但召回的内容东一块西一块,看不懂在说什么

这些问题,90% 是因为你的文档没有被正确地"切开"


二、为什么文本分割这么重要?

2.1 原理:AI 是怎么"读"文档的?

RAG 的流程是:


文档 → 分块 → 向量化 → 存入向量库

                                    ↓

用户问题 → 向量化 → 检索最相似的块 → 送给 LLM

关键点:AI 不是读整篇文档,而是一次只读一个块

如果这个块太小 → 上下文不够,AI 看不懂

如果这个块太大 → 语义太杂,AI 注意力分散

如果切的位置不对 → 把一句完整的话切成两半,意思全变了

2.2 一个真实的失败案例

某客户的产品手册,每页是一个产品型号,格式是:

【产品名称】

XXX

【产品型号】

XXX

【功能特点】

  • 功能1: xxx
  • 功能2: xxx

最初用固定 500 字分块,结果:

  • 第一个块包含了"产品名称"和"产品型号"
  • 第二个块从"功能特点"中间断开
  • 第三个块只有后半部分功能

检索"这个产品有哪些功能"时,召回的内容全是碎的,用户体验极差。

后来改成按"【】"标题分割,每个块都是一个完整的产品卡片,问题立刻解决。


三、四种文本分割策略

3.1 固定长度分割(最简单,但效果最差)


from langchain.text_splitter import CharacterTextSplitter

  

splitter = CharacterTextSplitter(

    chunk_size=500,

    chunk_overlap=50

)

chunks = splitter.split_text(text)

问题:完全不考虑语义,该断的地方不断,不该断的地方乱断。

适用场景:只有纯文本,没有任何结构


3.2 递归字符分割(推荐默认选项)

Langchain 推荐的做法,按优先级逐层尝试分割:


from langchain.text_splitter import RecursiveCharacterTextSplitter

  

splitter = RecursiveCharacterTextSplitter(

    chunk_size=256,       # 目标块大小(字符)

    chunk_overlap=64,     # 重叠区域

    separators=[

        "\n\n",           # 第一优先:按段落分割

        "\n",             # 第二优先:按换行分割

        "。",             # 第三优先:按句号分割

        ",",             # 第四优先:按逗号分割

        ""                # 最后:按字符分割

    ]

)

chunks = splitter.split_text(text)

原理

  1. 先按 \n\n(段落)分割

  2. 如果段落太长,按 \n(换行)分割

  3. 如果还太长,按句号分割

  4. 以此类推,直到块足够小

实测效果:比固定长度分割,召回率提升 15-20%


3.3 按标题/结构分割(最适合有结构的文档)

对于有明确结构的文档(手册、报告、合同),按结构分割效果最好:


from langchain.text_splitter import MarkdownTextSplitter

  

# 按 Markdown 标题分割

splitter = MarkdownTextSplitter(

    chunk_size=500,

    overlap=50

)

chunks = splitter.split_text(markdown_text)

或者用更灵活的方案:


import re

from langchain.text_splitter import TextSplitter

  

class StructuredSplitter(TextSplitter):

    def __init__(self, pattern, **kwargs):

        self.pattern = re.compile(pattern)

        super().__init__(**kwargs)

  

    def split_text(self, text):

        # 按章节标题分割

        sections = self.pattern.split(text)

        return [s.strip() for s in sections if s.strip()]

  

# 按 "第X章" 或 "【XXX】" 分割

splitter = StructuredSplitter(

    pattern=r'(第\d+章|【[^】]+】)',

    chunk_size=500

)

chunks = splitter.split_text(contract_text)


3.4 语义分割(进阶方案,效果最好但实现复杂)

用 Embedding 模型判断"语义断点",在意义完整的地方才切断:


from langchain_experimental.text_splitter import SemanticChunker

from langchain_openai import OpenAIEmbeddings

  

# 按语义断点分割

splitter = SemanticChunker(

    embeddings=OpenAIEmbeddings(),

    breakpoint_threshold_amount=0.95  # 相似度阈值

)

chunks = splitter.create_documents([long_text])

原理

  1. 把文本切成句子

  2. 计算相邻句子的 Embedding 相似度

  3. 相似度突然下降的地方 = 语义断点

  4. 在断点处切断

效果:比递归分割,召回率再提升 5-10%

缺点:速度慢,成本高(需要调用更多 Embedding API)。


四、分块参数的实战经验

4.1 块大小:256 是中文最优值

| 块大小 | 召回率 | 精确率 | 适用场景 |

|--------|--------|--------|----------|

| 128 | 中 | 高 | 语义极密集的内容 |

| 256 | | | 大多数中文文档 |

| 512 | 高 | 中 | 结构清晰的长文档 |

| 1024 | 中 | 低 | 不推荐 |

为什么是 256?

  • 中文 256 字符 ≈ 300-400 token

  • 刚好够 LLM 理解一小段完整内容

  • 又不至于信息太杂

4.2 重叠:20-25% 是黄金比例


chunk_overlap = int(chunk_size * 0.25)  # 256 * 0.25 = 64

作用:防止块边界切断语义,让检索更连贯。


五、Embedding 模型的选择与优化

5.1 为什么 Embedding 决定召回上限?

如果说文本分割是"切菜",Embedding 就是"把菜变成向量"。

如果菜切得再整齐,但向量转化得不对,AI 依然找不到。

5.2 中文场景模型选型

| 模型 | 中文效果 | 速度 | 推荐度 |

|------|----------|------|--------|

| BGE-large-zh | ⭐⭐⭐⭐⭐ | 中 | 首选 |

| BGE-base-zh | ⭐⭐⭐⭐ | 快 | 备选 |

| M3E | ⭐⭐⭐⭐ | 快 | 特定场景 |

| text2vec | ⭐⭐⭐ | 快 | 轻量场景 |

5.3 Embedding 实战代码


from langchain_community.embeddings import HuggingFaceBgeEmbeddings

  

# 初始化(支持本地部署,数据不出网)

embedding = HuggingFaceBgeEmbeddings(

    model_name="BAAI/bge-large-zh",

    model_kwargs={

        "device": "cpu"  # 或 "cuda" 如果有 GPU

    },

    encode_kwargs={

        "normalize_embeddings": True  # 归一化,检索更准

    }

)

  

# 单句向量化

query_vec = embedding.embed_query("劳动合同最长期限是多久?")

print(f"向量维度: {len(query_vec)}")

  

# 批量向量化

doc_vecs = embedding.embed_documents([

    "第一章 总则",

    "第二章 劳动者的权利和义务",

    "第三章 劳动合同的订立"

])


六、完整实战:从文档到向量


from langchain_community.document_loaders import PyPDFLoader

from langchain.text_splitter import RecursiveCharacterTextSplitter

from langchain_community.embeddings import HuggingFaceBgeEmbeddings

from langchain_community.vectorstores import Qdrant

  

# 1. 加载文档

loader = PyPDFLoader("产品手册.pdf")

pages = loader.load_and_split()

  

# 2. 文本分割

splitter = RecursiveCharacterTextSplitter(

    chunk_size=256,

    chunk_overlap=64,

    separators=["\n\n", "\n", "。", ","]

)

chunks = splitter.split_documents(pages)

  

# 3. 向量化

embedding = HuggingFaceBgeEmbeddings(model_name="BAAI/bge-large-zh")

  

# 4. 存入向量数据库

vectorstore = Qdrant.from_documents(

    documents=chunks,

    embedding=embedding,

    client=client,

    collection_name="product_manual"

)

  

print(f"✅ 处理完成:{len(pages)} 页 → {len(chunks)} 个块")


七、常见问题与解决方案

| 问题 | 原因 | 解决方案 |

|------|------|----------|

| 召回率低 | 块太小或太大 | 调 chunk_size 到 256 |

| 检索不连贯 | 块之间没重叠 | 加 chunk_overlap |

| 相关内容搜不到 | Embedding 模型不对 | 换成 BGE-large-zh |

| 结构化文档效果差 | 没按结构分割 | 用 MarkdownSplitter |

| 多文档效果不稳定 | 每个文档格式不同 | 先统一格式再分割 |


总结

三个核心要点:

  1. 文本分割是 RAG 的第一步,也是最容易出问题的步骤

  2. 256 字块 + 64 字重叠,是中文文档的最优默认参数

  3. 有结构的文档(手册、合同)按结构分割,效果比固定长度好 30%+


下一步

文本分割看起来是"体力活",但其实是最能拉开差距的地方。

很多人搭建知识库效果不好,不是算法不行,是文档没切对。

搞定分割,下一步就是调优。下一篇《RAG 效果调优》会讲 5 个核心参数怎么调。

如果你也在处理文档分割的问题,欢迎扫码聊一聊。

我可以帮你评估现有文档结构,给出具体的分割策略建议。

备注"分割",送你一份《常见文档分割策略对比表》👇


📌 关联阅读

关注「林间昭语」公众号,回复以下关键词领取资料:

  • 回复"知识库" → 领取《RAG 交付自检清单》
  • 回复"分割" → 领取《常见文档分割策略对比表》
  • 回复"选型" → 领取《向量数据库选型评估表》
  • 回复"调优" → 领取《RAG 调优参数手册》

关注「林间昭语」,用技术创造可能。

点击上方蓝色公众号名称 → 设为星标 🌟,第一时间收到干货。