Sunday 的面试指南

RAG 文档处理:解析、清洗、分块与版本更新

《Agent 大模型 0 到 1 系统课》 · 程序员 Sunday

上一节,咱们已经实现了一个向量检索器。

用户问一句话,程序会把问题转换成向量,再和知识库里的文档向量做相似度计算,最后取出 TopK。

做到这里,RAG 的检索链路已经像点样了。

但是,上一节的知识库其实太理想化了。

每一份资料都只有几行,而且都是咱们手写在代码里的:

RAG 文档处理:解析、清洗、分块与版本更新 配图 1

在真实企业项目里面这肯定不行。

真实企业里的资料会是各种各样的格式,比如: Markdown、PDF、Word,甚至是:飞书文档、后台配置、数据库记录、音频、视频 都有可能。

并且这些资料还可能会变!

比如:现在是「金额超过 2000 需要人工」,以后可能是「金额超过 3000 才需要人工」

这种资料的更新也是非常重要的场景。

所以说,这一小节,咱们就得来看一个在实际开发场景中非常重要的问题

那就是:一份原始资料,应该怎样变成适合 RAG 检索的数据?以及如何保证数据的更新?

这就是 文档解析、清洗、分块、Metadata、Chunk ID 和 版本更新 要解决的事。

RAG 入库前到底要做什么

咱们还是从一个例子开始。

假设,现在公司有一份《售后手册.md》文件,这个文件有好几万字,大概描述的就是下面这些东西:

# 售后手册

## 退款规则
普通商品签收后 7 天内可以申请退款。
退款金额超过 2000 元时,需要进入人工审核流程。

## 发货规则
现货商品会在付款后 48 小时内发货。
偏远地区可能增加 1 到 3 天配送时间。

## 发票规则
订单完成后可以申请电子发票。
企业发票需要提供公司抬头和税号。

## 保修规则
电器商品享受 1 年整机保修。
人为损坏、进水和自行拆机不在免费保修范围内。

那么,假设咱们现在已经把这个数据存到向量库里面了。

现在用户问:“3000 元退款需要人工审核吗?”

这个问题要用到的,其实只有 “退款规则” 这一小段内容。其他的那几条规则都没啥用

但是,如果咱们直接把整份《售后手册》拿去做 Embedding,向量数据库里就只有一条数据,那就是 售后手册全文 -> 一个向量

验证一下

光这么说还是有点抽象。

所以,咱们直接用上一节 03-memory-vector-search.js 的思路做一个验证。

还是老指令:

mkdir 04-document-chunking
cd 04-document-chunking

然后把 03-memory-vector-search 里面的代码,复制到 04-document-chunking 中。

memory-vector-search.js 的代码需要改一点,但是大致的内容是不变的。

这里就不给大家把整个代码都贴上了,只给大家几个关键表示的部分

想要源码可以看这里:https://github.com/lgd8981289/Agent--Code

// 模拟用户问题。
const query = '3000 元退款需要人工审核吗?'

// 方案一:把整份售后手册当成一个检索单位。
// 这样做的问题是:一旦命中,返回的是整篇文档,里面会混入很多无关内容。
const wholeManual = {
	id: 'after-sales-manual-full',
	title: '售后手册全文',
	type: 'whole-document',
	content: `# 售后手册

## 退款规则
普通商品签收后 7 天内可以申请退款。
退款金额超过 2000 元时,需要进入人工审核流程。
用户提交退款申请后,系统会先校验订单状态、签收时间和商品类型。
如果订单命中人工审核规则,退款申请会进入客服审核队列。

## 发货规则
现货商品会在付款后 48 小时内发货。
偏远地区可能增加 1 到 3 天配送时间。
如果订单中包含预售商品,整单会按照预售商品的发货时间处理。

## 发票规则
订单完成后可以申请电子发票。
企业发票需要提供公司抬头和税号。
发票开具后会发送到用户邮箱。

## 保修规则
电器商品享受 1 年整机保修。
人为损坏、进水和自行拆机不在免费保修范围内。

## 优惠券规则
优惠券需要在有效期内使用。
已经过期的优惠券不能恢复,也不能兑换成现金。

## 会员积分规则
用户完成订单后可以获得积分。
积分可以在积分商城兑换优惠券。`
}

// 方案二:把售后手册提前拆成多个 Chunk。
// 每个 Chunk 只保存一个相对独立的规则片段。
// 这样检索时更容易命中和用户问题真正相关的内容。
const chunkDocuments = [
	{
		id: 'refund-rule-chunk',
		title: 'Chunk 1:退款规则',
		type: 'chunk',
		content: `普通商品签收后 7 天内可以申请退款。
退款金额超过 2000 元时,需要进入人工审核流程。
用户提交退款申请后,系统会先校验订单状态、签收时间和商品类型。
如果订单命中人工审核规则,退款申请会进入客服审核队列。`
	},
	{
		id: 'shipping-rule-chunk',
		title: 'Chunk 2:发货规则',
		type: 'chunk',
		content: `现货商品会在付款后 48 小时内发货。
偏远地区可能增加 1 到 3 天配送时间。
如果订单中包含预售商品,整单会按照预售商品的发货时间处理。`
	},
	{
		id: 'invoice-rule-chunk',
		title: 'Chunk 3:发票规则',
		type: 'chunk',
		content: `订单完成后可以申请电子发票。
企业发票需要提供公司抬头和税号。
发票开具后会发送到用户邮箱。`
	},
	{
		id: 'warranty-rule-chunk',
		title: 'Chunk 4:保修规则',
		type: 'chunk',
		content: `电器商品享受 1 年整机保修。
人为损坏、进水和自行拆机不在免费保修范围内。`
	},
	{
		id: 'coupon-rule-chunk',
		title: 'Chunk 5:优惠券规则',
		type: 'chunk',
		content: `优惠券需要在有效期内使用。
已经过期的优惠券不能恢复,也不能兑换成现金。`
	},
	{
		id: 'points-rule-chunk',
		title: 'Chunk 6:会员积分规则',
		type: 'chunk',
		content: `用户完成订单后可以获得积分。
积分可以在积分商城兑换优惠券。`
	}
]
。。。
async function main() {
  ...

	// 方案一:只在“整份文档”里检索。
	// 因为只有一份完整手册,所以 TopK 返回的一定是整份手册。
	const wholeDocumentResults = searchTopK({
		queryVector,
		store: vectorStore.filter((document) => document.type === 'whole-document'),
		topK: 1
	})

	// 方案二:只在“Chunk 文档”里检索。
	// 这样可以观察模型是否能命中更具体的“退款规则 Chunk”。
	const chunkResults = searchTopK({
		queryVector,
		store: vectorStore.filter((document) => document.type === 'chunk'),
		topK: 1
	}) 
  ...
}

这个代码咱们其实是做了两组实验。

第一组 wholeManual :把整份《售后手册》当成一个文档做 Embedding。

第二组 chunkDocuments :把同一份《售后手册》拆成多个 Chunk,再分别做 Embedding。

为了让差异看得更清楚,两组实验都只取 TopK = 1。

也就是说,我们只看“最相关的第一条”到底是什么。

执行代码:

node --env-file=.env memory-vector-search.js

运行以后,第一组会打印整个文档的所有内容:

RAG 文档处理:解析、清洗、分块与版本更新 配图 2

这个结果说明啥呢?

这个结果说明:如果整份文档只生成一个向量,那么 TopK 只能返回整份《售后手册》。

它里面虽然包含退款规则,但是同时也包含了很多其他不需要的内容

也就是说,模型拿到的是一整份混合内容。

然后再看第二组:

RAG 文档处理:解析、清洗、分块与版本更新 配图 3

这个结果就很清楚了。

同样的问题,同样的 Embedding 模型,同样的 TopK 逻辑。

当检索单位变成 Chunk 以后,排在第一位的就不是整份手册,而是更精准的 退款规则 Chunk。

同时,准备塞给模型的上下文长度也从 405 字变成了 121 字。

到这里,前面说的 售后手册全文 -> 一个向量 大家应该就可以明白是啥意思了

当然,这样勉强也能用。

但是问题在于,这个检索单位太粗了。

上一节咱们讲 TopK 的时候,提到过:知识库里的每一条数据,都是一个可以被排序、被召回的检索单位。

因此,如果想要实现 RAG 入库,那么第一个要解决的问题就是 数据资料应该如何被拆分!

我们必须把一个大的数据拆分成不同的小块。即:先把原始文档切成更适合检索的小片段,再对这些小片段做 Embedding。

这就是 RAG 中的「切块」操作。

其中每一个小块,都被叫做一个 Chunk。

而这种把相关内容找回来的过程,就是 召回

比如刚才那份《售后手册》,就可以先切成下面几块:

Chunk 1:退款规则
普通商品签收后 7 天内可以申请退款。
退款金额超过 2000 元时,需要进入人工审核流程。

Chunk 2:发货规则
现货商品会在付款后 48 小时内发货。
偏远地区可能增加 1 到 3 天配送时间。

Chunk 3:发票规则
订单完成后可以申请电子发票。
企业发票需要提供公司抬头和税号。

Chunk 4:保修规则
电器商品享受 1 年整机保修。
人为损坏、进水和自行拆机不在免费保修范围内。

这样一来,向量数据库里存的就不再是一条大的 “售后手册全文向量”。

而是几条更小、更明确的 Chunk 向量了:

退款规则 Chunk  →  一个向量
发货规则 Chunk  →  一个向量
发票规则 Chunk  →  一个向量
保修规则 Chunk  →  一个向量

说到这里,为什么要分块,应该就比较清楚了。

但是,咱们得注意一点,那就是:Chunk 也不是越小越好的。

太大了的问题咱们前面已经知道了。

但是,如果太小了,也会出问题。

比如,把这句话切成两段:

  • 退款金额超过 2000 元时
  • 需要进入人工审核流程

单独看第一段,模型不知道超过 2000 元以后要干啥。

单独看第二段,模型又不知道什么条件下需要人工审核。

所以,一个好的 Chunk,应该尽量表达一个相对完整的意思。

更准确地说,Chunk 应该是:RAG 检索时能够独立作为答案依据的一小段内容。

但是,只有 Chunk 还不够。

很多同学会把一个 Chunk 设计成这样:

{
	content: '退款金额超过 2000 元时,需要进入人工审核流程。'
}

这样能不能做向量检索?

能。

但是它 不可维护

因为真实项目里,一旦检索到了这段内容之后,咱们还需要继续处理几个问题。

  • 这段内容来自哪份文档?
  • 它属于退款、发货,还是发票?
  • 它是哪个版本的规则?
  • 如果用户质疑答案,咱们能不能回到原文?
  • 如果文档更新了,咱们怎么知道应该删掉哪些旧向量?

所以,一个真正能维护的 Chunk,应该是一个完成的 JSON 对象。

比如,下面这样:

{
	chunkId: 'refund-policy:2026-06-01:003:a8f12c9b7e01',
	content: '退款金额超过 2000 元时,需要进入人工审核流程。',
	metadata: {
		source: 'refund-policy.md',
		title: '蓝鲸退款规则',
		category: 'refund',
		owner: 'customer-service',
		sourceVersion: '2026-06-01',
		chunkIndex: 3,
		contentHash: 'a8f12c9b7e01'
	}
}

其中:

  • source 可以告诉咱们答案来自哪份文档
  • category 可以让后面的检索在指定的哪一个库做查询
  • sourceVersion 可以判断这段内容属于哪个版本
  • chunkIndex 可以知道它在原文档里的顺序。
  • contentHash 可以判断这段内容有没有发生变化。
  • 而 chunkId 则是这条 Chunk 在系统里的唯一身份。

这些字段合在一起,就是这一节要讲的第二个核心东西:Metadata。

Metadata 大家可以理解为是一个综合信息体。咱们后面做来源引用、权限隔离、分类检索、版本更新、问题排查,都会依赖这个东西

那么到这里,整个 RAG 入库前的流程基本上就有了:

RAG 文档处理:解析、清洗、分块与版本更新 配图 4
  • 文档解析,解决的是:原始文件里的内容怎么读出来。比如 Markdown 文件可以直接读取文本;PDF 需要解析页面;Word 需要解析段落;网页需要抽取正文。这一节为了让大家先看清主流程,只处理 Markdown。复杂格式后面做企业知识库的时候再接。
  • 文档清洗,解决的是:读出来的文本能不能直接用于检索。有些文本里会有多余空行、连续空格、页眉页脚、导航文字、版权声明,甚至 OCR 错字。如果不清洗,这些噪声也会进入 Embedding,影响检索结果。
  • 文档分块,解决的是:一份文档应该拆成多大的检索单元。块太大,检索不够精准,也浪费 Token。块太小,语义可能被切断。
  • Metadata,解决的是:这个 Chunk 从哪里来、属于什么业务、后面怎么过滤和追踪。

用 Node.js 实现文档解析、清洗和分块

接下来,咱们写一个最小案例。

这个案例只做一件事,那就是:把本地 Markdown 文档处理成一批标准 Chunk。 最后会生成一个 chunks.json 文件。

这个文件就是下一节写入向量数据库之前的数据形态。

代码地址还是放在:https://github.com/lgd8981289/Agent--Code

创建项目

还是在 04-document-chunking 文件夹里面

然后咱们要创建两个文件夹:

mkdir documents output

再创建一个 package.json:

{
  // 因为后面的代码会使用 `import` 语法,所以这里明确告诉 Node.js 按照 ESM 方式执行。
  "type": "module"
}

整个结构创建好之后,大概长这样:

RAG 文档处理:解析、清洗、分块与版本更新 配图 5

准备两份 Markdown 文档

先创建 documents/refund-policy.md:

# 蓝鲸退款规则
category: refund
owner: customer-service
version: 2026-06-01

普通商品签收后 7 天内可以申请退款。

生鲜商品不支持无理由退款。

退款金额超过 2000 元时,需要进入人工审核流程。

用户提交退款申请后,系统会先校验订单状态、签收时间和商品类型。

如果订单命中人工审核规则,退款申请会进入客服审核队列。
客服审核通过后,系统再进入退款打款流程。

再创建 documents/shipping-policy.md:

# 商品发货规则
category: shipping
owner: fulfillment
version: 2026-06-01

现货商品会在付款后 48 小时内发货。

偏远地区可能增加 1 到 3 天配送时间。

如果订单中包含预售商品,整单会按照预售商品的发货时间处理。

用户可以在订单详情页查看物流单号。
物流信息通常会在发货后 24 小时内更新。

编写文档处理代码

PS 插一嘴:

真实项目里不会所有解析、分块逻辑都自己手写。

Markdown 可以用 remark/unified 做结构化解析。

RAG 项目可以用 LangChain JS 的 Document Loader 和 Text Splitter。

更复杂的 PDF、扫描件和表格,可以交给 LlamaParse 这类专门的文档解析服务。

但这一节先手写,目的是为了让大家看清 RAG 入库前到底发生了什么。等后面讲 LangChain 和企业知识库时,再用现成库就行

创建 build-chunks.js:

import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import path from 'node:path'

// 原始 Markdown 文档所在目录。
// 程序会读取 documents 目录下的所有 .md 文件。
const documentDir = path.join(process.cwd(), 'documents')

// 切块结果输出目录。
const outputDir = path.join(process.cwd(), 'output')

// 最终生成的 Chunk 数据文件。
const outputFile = path.join(outputDir, 'chunks.json')

// 每个 Chunk 的最大长度,默认 120 个字符。
// 真实项目中一般会按 Token 数量控制,这里为了演示,先按字符数控制。
const chunkMaxLength = 120

// Chunk 之间的重叠长度,默认 40 个字符。
// overlap 可以减少上下文被切断的问题。
const chunkOverlapLength = 40

/**
 * 计算文本的 sha256 哈希值。
 *
 * 这里主要用于生成 contentHash,
 * 方便判断 Chunk 内容是否发生变化。
 */
function sha256(text) {
	return createHash('sha256').update(text).digest('hex')
}

/**
 * 对原始文本做基础清洗。
 *
 * 主要处理:
 * 1. 统一换行符
 * 2. 替换 tab
 * 3. 合并多余空格
 * 4. 合并过多空行
 * 5. 去掉首尾空白
 */
function normalizeText(text) {
	return text
		.replace(/\r\n/g, '\n')
		.replace(/\t/g, ' ')
		.replace(/[ ]{2,}/g, ' ')
		.replace(/\n{3,}/g, '\n\n')
		.trim()
}

/**
 * 从 Markdown 文档里读取简单的元信息。
 *
 * 例如:
 * category: after-sales
 * owner: customer-service
 * version: v1
 *
 * 如果没有读取到,就使用 fallback 默认值。
 */
function readMetaLine(lines, name, fallback) {
	const line = lines.find((item) => item.startsWith(`${name}:`))

	if (!line) {
		return fallback
	}

	return line.replace(`${name}:`, '').trim()
}

/**
 * 解析 Markdown 文档。
 *
 * 这一步会从原始 Markdown 中提取:
 * - 文件名
 * - 标题
 * - category
 * - owner
 * - version
 * - 正文内容
 */
function parseMarkdown({ fileName, rawText }) {
	const normalizedText = normalizeText(rawText)
	const lines = normalizedText.split('\n')

	// 默认把一级标题作为文档标题。
	// 如果文档里没有一级标题,就使用文件名作为标题。
	const titleLine = lines.find((line) => line.startsWith('# '))
	const title = titleLine?.replace(/^#\s+/, '').trim() ?? fileName

	// 从文档中读取 Metadata。
	const category = readMetaLine(lines, 'category', 'unknown')
	const owner = readMetaLine(lines, 'owner', 'unknown')
	const sourceVersion = readMetaLine(lines, 'version', 'v1')

	// 正文中不再保留 category / owner / version 这些元信息行。
	// 这些信息会被放到 metadata 字段里。
	const body = lines
		.filter((line) => !line.startsWith('category:'))
		.filter((line) => !line.startsWith('owner:'))
		.filter((line) => !line.startsWith('version:'))
		.join('\n')

	return {
		fileName,
		title,
		category,
		owner,
		sourceVersion,
		text: normalizeText(body)
	}
}

/**
 * 处理超长段落。
 *
 * 如果某个段落本身已经超过 chunkMaxLength,
 * 就只能按照固定长度继续切成多个小片段。
 */
function splitLongParagraph(paragraph) {
	const parts = []

	for (let start = 0; start < paragraph.length; start += chunkMaxLength) {
		parts.push(paragraph.slice(start, start + chunkMaxLength).trim())
	}

	return parts.filter(Boolean)
}

/**
 * 把文档正文拆成段落数组。
 *
 * 规则:
 * - 先按空行拆分段落
 * - 段落内部的换行替换成空格
 * - 过滤空段落
 * - 如果段落太长,再继续切小
 */
function splitIntoParagraphs(text) {
	return text
		.split(/\n\s*\n/)
		.map((paragraph) => paragraph.replace(/\n/g, ' ').trim())
		.filter(Boolean)
		.flatMap((paragraph) => {
			if (paragraph.length <= chunkMaxLength) {
				return [paragraph]
			}

			return splitLongParagraph(paragraph)
		})
}

/**
 * 从当前 Chunk 末尾取一段文本作为 overlap。
 *
 * overlap 的作用是:
 * 让相邻 Chunk 之间保留一点重复内容,
 * 避免重要语义刚好被切断。
 */
function takeOverlap(text) {
	if (chunkOverlapLength <= 0) {
		return ''
	}

	return text.slice(-chunkOverlapLength).trim()
}

/**
 * 生成稳定的 Chunk ID。
 *
 * Chunk ID 中包含:
 * - 来源文件名
 * - 来源版本
 * - Chunk 序号
 * - 内容哈希
 *
 * 这样做的好处是:
 * 当文档内容或版本变化时,可以更容易识别哪些 Chunk 发生了变化。
 */
function toChunkId({ fileName, sourceVersion, chunkIndex, content }) {
	const sourceName = fileName.replace(/\.md$/, '')
	const contentHash = sha256(content).slice(0, 12)
	const indexText = String(chunkIndex).padStart(3, '0')

	return `${sourceName}:${sourceVersion}:${indexText}:${contentHash}`
}

/**
 * 把一份文档切成多个 Chunk。
 *
 * 整体流程:
 * 1. 先把正文拆成段落
 * 2. 尽量把多个段落合并成一个 Chunk
 * 3. 如果超过最大长度,就结束当前 Chunk
 * 4. 新 Chunk 开头带上一点 overlap
 * 5. 最后为每个 Chunk 补充 metadata
 */
function createChunks(document) {
	const paragraphs = splitIntoParagraphs(document.text)

	const chunks = []
	let current = []

	for (const paragraph of paragraphs) {
		// 尝试把当前段落加入正在构建的 Chunk。
		const nextText = [...current, paragraph].join('\n\n')

		// 如果加入后超过最大长度,就先保存当前 Chunk。
		if (current.length > 0 && nextText.length > chunkMaxLength) {
			const content = current.join('\n\n')
			chunks.push(content)

			// 从上一个 Chunk 的末尾取一小段作为下一个 Chunk 的开头。
			const overlap = takeOverlap(content)
			current = overlap ? [overlap, paragraph] : [paragraph]
			continue
		}

		current.push(paragraph)
	}

	// 循环结束后,如果还有未保存的内容,需要补上最后一个 Chunk。
	if (current.length > 0) {
		chunks.push(current.join('\n\n'))
	}

	// 把普通字符串 Chunk 转成结构化数据。
	return chunks.map((content, index) => {
		const chunkIndex = index + 1
		const contentHash = sha256(content).slice(0, 12)

		return {
			chunkId: toChunkId({
				fileName: document.fileName,
				sourceVersion: document.sourceVersion,
				chunkIndex,
				content
			}),

			// Chunk 的正文内容。
			// 后续会对这个 content 做 Embedding。
			content,

			// Chunk 的元信息。
			// 后续检索、过滤、展示来源、版本更新都会用到这些信息。
			metadata: {
				source: document.fileName,
				title: document.title,
				category: document.category,
				owner: document.owner,
				sourceVersion: document.sourceVersion,
				chunkIndex,
				contentHash,
				chunkLength: content.length
			}
		}
	})
}

/**
 * 从 documents 目录中加载所有 Markdown 文档。
 *
 * 这里只处理 .md 文件,并且按照文件名排序,
 * 这样每次运行时的处理顺序更稳定。
 */
async function loadMarkdownDocuments() {
	const entries = await readdir(documentDir, { withFileTypes: true })

	const markdownFiles = entries
		.filter((entry) => entry.isFile())
		.filter((entry) => entry.name.endsWith('.md'))
		.map((entry) => entry.name)
		.sort()

	const documents = []

	for (const fileName of markdownFiles) {
		const filePath = path.join(documentDir, fileName)
		const rawText = await readFile(filePath, 'utf8')

		// 读取文件后,立刻解析成统一的文档结构。
		documents.push(parseMarkdown({ fileName, rawText }))
	}

	return documents
}

async function main() {
	// 基础参数校验。
	if (chunkMaxLength <= 0) {
		throw new Error('CHUNK_MAX_LENGTH 必须大于 0。')
	}

	if (chunkOverlapLength < 0) {
		throw new Error('CHUNK_OVERLAP_LENGTH 不能小于 0。')
	}

	// 读取 Markdown 文档。
	const documents = await loadMarkdownDocuments()

	// 对每份文档进行切块。
	// flatMap 会把多份文档生成的 Chunk 合并成一个数组。
	const chunks = documents.flatMap(createChunks)

	// 确保 output 目录存在。
	await mkdir(outputDir, { recursive: true })

	// 把切块结果写入 chunks.json。
	// 后续可以继续读取这个文件,再做 Embedding 和入库。
	await writeFile(outputFile, JSON.stringify(chunks, null, 2), 'utf8')

	console.log(`文档数量:${documents.length}`)
	console.log(`Chunk 数量:${chunks.length}`)
	console.log(`已写入:${outputFile}`)

	console.log('\n前 3 个 Chunk:')

	// 打印前 3 个 Chunk 的核心信息,方便快速检查切块结果。
	console.table(
		chunks.slice(0, 3).map((chunk) => ({
			chunkId: chunk.chunkId,
			title: chunk.metadata.title,
			category: chunk.metadata.category,
			version: chunk.metadata.sourceVersion,
			length: chunk.metadata.chunkLength
		}))
	)
}

main()

这段代码稍微有点多,但是整体难度并不多。

因为 咱们没必要搞明白里面每一行代码的意思。 这些代码我也都是 Vibe Coding 写,然后自己稍微调整一下(很多都不用调整)就可以了

上面的代码,大家只需要知道他干了 4 件事:

  • 读取 documents 目录里的 Markdown
  • 解析标题、分类、负责人和版本号
  • 按段落把正文切成多个 Chunk
  • 给每个 Chunk 生成 chunkId 和 metadata

给大家把代码大致的捋一遍,大家能看到我捋的足足够了

先看 parseMarkdown()。

它负责把一份 Markdown 文档解析成标准对象。

比如 refund-policy.md 解析完以后,大概会变成这样:

{
	fileName: 'refund-policy.md',
	title: '蓝鲸退款规则',
	category: 'refund',
	owner: 'customer-service',
	sourceVersion: '2026-06-01',
	text: '...清洗后的正文...'
}

这里的 text 就是后面要分块的正文。

而 title、category、owner、sourceVersion,后面都会进入每个 Chunk 的 metadata。

然后是 normalizeText()。

它负责做最基础的清洗。

比如把 Windows 换行统一成 \n,把 Tab 变成空格,把连续多个空格压成一个,把过多空行压缩掉。

splitIntoParagraphs() 和 createChunks()

这两个方法负责分块。

咱们这里没有按固定字数切,而是先按段落切块。

因为政策文档、客服手册、FAQ 这类资料,本身就经常按段落表达一个相对完整的意思。

RAG 文档处理:解析、清洗、分块与版本更新 配图 6

如果一段内容太长,超过了 CHUNK_MAX_LENGTH,代码才会继续按长度拆开。

这里还有一个 CHUNK_OVERLAP_LENGTH,它的作用是给相邻 Chunk 留一点重叠内容。

比如:一个规则刚好出现在上一个 Chunk 的结尾,完全不重叠的话,下一个 Chunk 可能会丢掉一点上下文。

使用 overlap 可以解决这个问题。不过 overlap 也不能太大。太大会让多个 Chunk 重复内容变多,向量数据库里数据膨胀,检索结果里也容易出现一堆相似的重复片段。

最后是 toChunkId()

它生成的 ID 大概是这个格式:refund-policy:2026-06-01:003:a8f12c9b7e01

这几个部分分别表示:

  • refund-policy:文档名
  • 2026-06-01:文档版本
  • 003:Chunk 序号
  • a8f12c9b7e01:内容 Hash

运行项目

执行:

node build-chunks.js

正常情况下,终端会看到类似这样的结果:

RAG 文档处理:解析、清洗、分块与版本更新 配图 7

如果你完全按照上面的示例文档复制,默认会得到 4 个 Chunk。然后打开 output/chunks.json。

它是一个数组,大概就长成这样:

RAG 文档处理:解析、清洗、分块与版本更新 配图 8

里面的每一个对象,就是一个标准 Chunk。

资料更新了应该怎么处理

现在咱们模拟一次文档更新。

把 documents/refund-policy.md 里的版本号改一下:version: 2026-06-15

然后把退款规则改成:退款金额超过 3000 元时,需要进入人工审核流程。

RAG 文档处理:解析、清洗、分块与版本更新 配图 9

再次执行:

node build-chunks.js

这时你会发现,refund-policy 对应的 Chunk ID 和 version 都变了。

RAG 文档处理:解析、清洗、分块与版本更新 配图 10

真实项目里,向量数据库通常会按下面的思路更新:

RAG 文档处理:解析、清洗、分块与版本更新 配图 11

这里也可以做得更细。

比如只比较 contentHash,只更新内容发生变化的 Chunk。

但是第一版系统不建议一上来做得太复杂。

对课程来说,大家先把主线搞清楚就行了

分块大小应该怎么设置

这一节代码里,咱们用了:

// 每个 Chunk 的最大长度,默认 120 个字符。
const chunkMaxLength = 120

// Chunk 之间的重叠长度,默认 40 个字符。
const chunkOverlapLength = 40

这个值只是为了让示例文档更容易切出多个 Chunk,方便大家观察分块效果。

真实项目里分块大小取决于你的 文档类型 和 检索目标 。

所以不能照抄这个大小。

下面给大家列了一下常见的分块大小(面试的时候常问),大家可以作为参考

场景Chunk 大小参考Overlap 参考
FAQ、客服规则、短事实问答100~300 字10%~20%
普通知识库文档、Markdown、产品文档300~800 字10%~20%
长报告、制度文档、法律/技术说明500~1000 字10%~20%
需要精确命中一句话的场景偏小一些少量重叠
需要上下文理解的场景偏大一些适当重叠

整个的核心原则就是:Chunk 要大到足够表达一个完整意思,又不能大到塞进太多无关信息。

总结

这一节其实就讲了一件事:一份原始资料,怎么变成适合 RAG 检索和后续维护的数据。

再给大家捋一下这一小节讲的内容哈:

最开始的时候,咱们先从一个问题开始,这个问题是:真实文档不可能都像上一节那样,刚好是一条条干净的小文本。

如果有一个《售后手册》,把它做成了一个向量,向量库里就只有一条数据。用户问退款问题时,TopK 最后也只能返回整份手册。

这个时候,检索单位就太粗糙了。

所以,这一节第一个核心概念就是 Chunk。

Chunk 可以理解为 RAG 里真正参与检索的一小段内容。

Chunk 太大,检索会不精准,还浪费 Context。

Chunk 太小,语义又可能被切断,模型拿到以后也很难判断。

所以分块不是一个纯粹的字数问题,而是:什么内容适合作为一次检索的基本单位。

然后,咱们又讲了 文档解析。

文档解析解决的是:原始资料怎么读出来。

Markdown、PDF、Word、网页、飞书文档,来源都不一样,但最后都要先变成可以继续处理的文本。

这一节为了把主流程讲清楚,只用了 Markdown (后面真实做企业知识库时,才会继续扩展到更多文档来源)

接着是 文档清洗。

文档清洗解决的是:读出来的文本能不能直接用于检索。

多余空行、连续空格、无意义导航、页眉页脚、版权声明,这些东西对 Embedding 来说都有用,也都需要进入向量表示。

再往后,就是 Metadata。

因为真实项目里,咱们不只要知道“检索到了什么内容”,还要知道这段内容来自哪里、属于什么业务、是什么版本、能不能做权限过滤

这些信息就放在 metadata 里。

然后是 Chunk ID。

Chunk ID 解决的是:每个 Chunk 在系统里的唯一身份。

一个标准的 Chunk ID ,应该由多个部分组成,比如:refund-policy:2026-06-01:003:a8f12c9b7e01

最后是 版本更新。

RAG 知识库不是一次性写死的。

今天规则是“超过 2000 元需要人工审核”,明天可能就改成“超过 3000 元需要人工审核”。

如果没有版本和内容 Hash,向量库里就可能同时存在旧规则和新规则。

这样 Agent 回答的时候,就会出现前后矛盾的问题

所以,版本信息和 contentHash 的作用,就是让系统知道文档有没有变、哪些 Chunk 更新了

到这里,咱们已经完成了 RAG 入库前最关键的一步。

下一节,咱们就可以把这些 Chunk 真正写入向量数据库了。

添加作者微信 · 购买完整课程

解锁完整课程 ¥499

《Agent 大模型 0 到 1 系统课》
扫码添加微信,备注「Agent 课程」,购买后由 Sunday 提供完整内容的学习方式。

扫码添加作者微信 LGD_Sunday,购买 499 元 Agent 课程

微信昵称:LGD_Sunday
手机上可长按保存二维码,再用微信扫一扫识别。

保存微信二维码