Embedding 解决了“如何比较两段文本是否相近”,但在真实知识库中,直接把整篇文档生成一个向量通常不够用:用户的问题可能只对应其中一个小节,检索结果还需要告诉我们它来自哪份文档、哪一页或哪一段。文档切分(chunking)负责把原文变成适合检索的片段,元数据(metadata)则负责保留每个片段的身份和来源。本篇只聚焦这两个基础设计,并用 Python 标准库完成一个可运行、无需网络的最小示例。

为什么要切分文档

把整篇文档压成一个向量,会把多个主题混在一起。比如一份部署手册同时包含安装、配置、备份和故障排查,用户询问“备份文件放在哪里”时,整篇向量只能表达一个模糊的综合主题,难以精确召回相关内容。

切分后的每个片段可以单独生成 Embedding、单独参与相似度比较,并在命中后作为上下文交给模型。切分也会影响最终效果:片段太小,语义不完整;片段太大,检索结果噪声多,还可能挤占上下文窗口。因此,切分不是机械地按固定字符数截断,而是在“语义完整”和“长度可控”之间做取舍。

一个实用的片段至少应满足三点:

  1. 能在脱离全文后理解自己的主要意思。
  2. 长度适合后续的向量化和上下文预算。
  3. 能追溯回原文,必要时可以展示标题、页码或链接。

先设计片段和元数据

可以把一个片段看成两部分:text 是参与向量化和检索的正文,metadata 是描述它的附加信息。常见字段包括:

  • document_id:文档的稳定标识,不要只依赖显示名称。
  • title:文档标题或章节标题,帮助模型理解上下文。
  • source:文件路径、网页地址或对象存储键。
  • section:章节名称,便于展示和过滤。
  • chunk_index:片段在原文中的顺序。
  • startend:片段在规范化文本中的字符范围,方便定位。

元数据不是越多越好。应区分“用于过滤的字段”和“用于展示、引用的字段”,并给字段定义稳定类型。例如页码应保持为整数,日期应统一格式;不要今天把 page 写成数字,明天又写成“第 3 页”。如果文档更新,最好根据内容生成版本或校验值,避免旧片段与新文档混在一起。

最小可运行的切分器

下面的示例按 Markdown 二级标题识别章节,再把每章按最大字符数切开。它不调用模型,也不假装字符数等于 token 数,因此可以先在本地验证边界和元数据。每个片段都会保留文档 ID、章节名、序号和原文范围。

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
from __future__ import annotations

from dataclasses import asdict, dataclass
import json
import re


@dataclass
class Chunk:
text: str
document_id: str
title: str
section: str
chunk_index: int
start: int
end: int


def split_document(text: str, document_id: str, title: str,
max_chars: int = 120) -> list[Chunk]:
"""按二级标题分组,并在章节内按字符上限切分。"""
heading = re.compile(r"^##\s+(.+)$", re.MULTILINE)
matches = list(heading.finditer(text))
sections: list[tuple[str, int, int]] = []

if not matches:
sections.append((title, 0, len(text)))
else:
if matches[0].start() > 0:
sections.append((title, 0, matches[0].start()))
for number, match in enumerate(matches):
end = matches[number + 1].start() if number + 1 < len(matches) else len(text)
sections.append((match.group(1).strip(), match.start(), end))

chunks: list[Chunk] = []
for section, section_start, section_end in sections:
section_text = text[section_start:section_end].strip()
for offset in range(0, len(section_text), max_chars):
piece = section_text[offset:offset + max_chars].strip()
if not piece:
continue
start = section_start + offset
chunks.append(Chunk(
text=piece,
document_id=document_id,
title=title,
section=section,
chunk_index=len(chunks),
start=start,
end=start + len(piece),
))
return chunks


if __name__ == "__main__":
document = """# 部署手册

## 安装
先创建虚拟环境,再安装项目依赖。

## 备份
每天把数据库导出文件保存到备份目录。"""
chunks = split_document(document, "deploy-guide-v1", "部署手册")
print(json.dumps([asdict(chunk) for chunk in chunks], ensure_ascii=False, indent=2))

保存为 chunk_demo.py 后运行 python chunk_demo.py,即可看到每个片段的 JSON 记录。chunk_index 使用生成顺序,startend 使用原始字符串的字符位置;如果后续先做了空白折叠、HTML 清理或 OCR 修正,就必须明确这些位置对应的是处理前还是处理后的文本,不能混用。

示例为了突出设计,采用了最简单的固定长度切分。实际项目可以先按标题、段落、列表和代码块等边界切分,再对超长段落做兜底截断。若相邻内容经常跨段落引用,可以加入少量 overlap,但重叠文本会增加向量数量和检索重复,应该通过测试集决定,而不是默认越多越好。

元数据如何参与检索

向量相似度只回答“哪些片段语义接近”,元数据可以补充业务约束。例如只检索 document_id 属于当前产品的片段,或只检索指定版本、语言和权限范围内的内容。权限过滤必须在返回上下文前完成,不能把所有片段交给模型后再指望模型自行隐藏敏感内容。

命中后,应用可以把 titlesectionsourcechunk_index 渲染为引用信息。若用户追问“这句话来自哪里”,程序应根据结构化字段生成链接或文件定位,而不是让模型凭记忆编造来源。保存 startend 也有助于调试:当召回结果不对时,可以回到原文检查切分边界是否把标题、表格或关键条件截断了。

常见问题

按字符数切分是不是按 token 切分? 不是。不同语言、代码和标点的 token 密度不同。字符上限适合做离线原型;接入具体 Embedding 或生成模型时,要根据 tokenizer 和上下文窗口重新设定预算。

每个片段都要重复加入文档标题吗? 通常值得加入。标题能提供主题线索,但要避免把很长的导航、页眉和页脚重复到每个片段中。可以把标题保存在元数据,也可以在送入模型的上下文中单独拼接,二者用途不同。

文档更新后如何处理旧向量? 为文档保存版本号或内容校验值。重新切分后批量写入新版本,检索时只选择当前版本;确认新数据可用后,再清理旧版本,避免更新过程出现空窗。

元数据能不能完全替代正文? 不能。元数据用于定位、过滤和解释,语义检索仍主要依赖片段正文的向量。若字段本身包含重要语义,例如产品名或章节名,应按需求把它拼入向量化文本,同时保留原字段用于展示和过滤。

小结

文档切分的目标是让片段既足够完整,又能在检索时精确命中;元数据的目标是让片段可过滤、可追溯、可引用。本文用标准库实现了按章节和长度切分,并为每个片段保存文档 ID、章节、顺序和字符范围。接下来进入向量数据库基础时,这些片段记录会成为写入索引的输入;提前把字段和版本规则设计清楚,能显著减少后续返工。