一、引言:一个99%的RAG初学者都会踩的坑
三个月前,我信心满满地搭好了公司知识库的第一个RAG原型——接入了LangChain、配好了OpenAI的Embedding接口、把部门文档一股脑灌进了向量库。跑第一条查询的时候,我傻眼了。
用户问"项目验收流程怎么走",检索结果里出现了三个几乎一模一样的会议纪要、一个只有5个字的页脚残片、还有一个HTML标签都没洗干净的页面抓取残渣。LLM看着这些垃圾,强行憋出了一段答非所问的回复。
那一刻我意识到:向量库不是万能熔炉,垃圾进去只会垃圾出来。
做后端的朋友可能会觉得这个场景似曾相识——这不就是数据仓库的老故事吗?
数据库 ETL: 业务表 → 清洗 → 转换 → 加载 → 数据仓库 → SQL查询
知识库 RAG Pipeline: 原始文档 → 清洗 → 分块 → Embedding → 向量库 → 语义检索
数据库ETL里的数据清洗已经是一个发展了二十年的成熟领域,有dbt、Great Expectations这样的专业工具,有完整的测试和数据质量监控体系。但在RAG这个"新世界"里,数据清洗常常被简化成一行 .strip(),甚至完全被忽略——开发者把所有注意力都放在"选什么Embedding模型"“用什么向量数据库”"怎么调Prompt"上,好像只要文档能塞进去,检索质量就会魔法般地好起来。
真相是:数据清洗是整个RAG Pipeline的第一道关卡,它决定了你后续所有环节的上限。 如果你的向量库里充斥着重复内容、HTML残渣、空白噪声和低信息密度片段,无论你用什么SOTA的Embedding模型、调多少轮Prompt,检索效果都不会好——因为问题出在数据的源头,后面的一切优化都是在垃圾上撒调料。
本系列文章将从一个TypeScript/React前端工程师的视角,手把手带你走完从数据清洗、文本分块到向量检索的完整RAG Pipeline搭建过程。每篇文章我都会结合真实的生产级代码(来自我们在做的KMS知识管理平台),让你不光知道"怎么做",更理解"为什么这么做"。
今天是第一篇:RAG数据清洗的五个递进层次。
准备好了吗?我们先从最简单的开始。
二、Level 1:基础文本清洗(5分钟实现,但90%的项目都会漏)
第一个层次解决的是最直观的文本噪音。说白了就是那些你肉眼能看到、但Embedding模型会困惑的"脏东西"。
我最早写的 basicClean() 函数长这样,至今仍然是我们Pipeline的第一步:
export function basicClean(text: string): string {
return text
// 1. 统一换行符(Windows \r\n → \n)
.replace(/\r\n/g, '\n')
// 2. 压缩连续空白(多个空格/制表符 → 单个空格)
.replace(/[ \t]+/g, ' ')
// 3. 压缩连续空行(多个连续换行 → 最多两个)
.replace(/\n{3,}/g, '\n\n')
// 4. 去除首尾空白
.trim()
// 5. Unicode 标准化(全角转半角等,中文场景必须)
.normalize('NFKC');
}
看起来简单得不可思议,对吧?但请看我第一次跑这段代码时记录的真实数据:
输入: "Hello World\r\n\r\n\r\n\r\nFoo\t\tBar "
输出: "Hello World\n\nFoo Bar"
5个冗余换行被压缩成1个,连续的Tab和空格被归一化,字符数从48减少到30——减少了37.5%。这还只是随手找的一段测试文本。真实的企业文档里,这种噪音往往是系统性的:爬虫抓取的网页有大量的 和非断行空格、从PDF提取的文本里夹杂着莫名其妙的控制字符、多人协作的Wiki页面里换行规范五花八门。
为什么连换行符都要管? 因为Text-Embedding模型把 "Hello World" 和 "Hello World" 当成两段不同的文本——多余空格会改变token边界,导致向量距离变大。也就是说,同样的语义内容,因为空白不一样,就会被模型判定为"不相似"。
| 脏数据类型 | 对RAG的影响 | 清洗后效果 |
|---|---|---|
| 多余空格/制表符 | 同内容产生不同向量距离 | 归一化,向量一致 |
Windows换行符 \r\n | \r 占用额外token,白白浪费 | 统一为 \n |
| 过多空行 | 干扰后续分块逻辑,可能把连续段落拆断 | 压缩至最多两个换行 |
| 全角字符/特殊Unicode | Embedding模型对全角半角不统一 | NFKC标准化统一 |
这里有个我特别想强调的点:.normalize('NFKC')。前端同学可能对Unicode标准化不太熟悉,但它在中文RAG场景里真的非常重要。它会把全角字符转为半角(A → A)、把合字拆成独立字符、把上标下标转成普通字符。不做这步处理,你的向量库里就是两个"不同的" A 和 A,但它们对用户来说完全是一个意思。
Level 1 虽然简单,但它是一个每天都在被跳过的步骤。下次你在任何一个RAG教程里看到 .strip(),问问自己:它做了Unicode标准化吗?它处理了 \r\n 吗?它压缩了连续空白吗?
三、Level 2:文档结构清洗(别让你的向量库变成HTML垃圾堆)
真实的文档不会以干净的纯文本出现。它们可能是:
- 从Confluence/语雀抓下来的HTML页面
- 同事写的Markdown技术文档
- 带页眉页脚的Word/PDF解析残渣
第二个层次的核心命题是:怎么剥离格式标签,同时保留内容的语义结构。
先说HTML清洗。不同于前端开发中"把DOM转成文本"那种简单的 textContent 操作,RAG场景下的HTML清洗需要更精细的控制——你不能简单地移除所有标签,因为标签本身携带了结构信息。
export function stripHtml(html: string): string {
return html
// 先移除 script/style 标签及其内容(完全是噪音)
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
// 将块级元素替换为换行(保留段落边界)
.replace(/<\/(div|p|h[1-6]|li|tr|section|article)>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
// 移除所有剩余标签
.replace(/<[^>]+>/g, '')
// HTML 实体解码
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)));
}
注意看第三行——我把 </div>、</p>、</h1> 等块级闭合标签替换成了换行符。这不是多此一举。如果直接删掉所有标签,原来分隔的两个段落就会粘成一大段:
原文: <p>第一段内容</p><p>第二段内容</p>
错误清洗: "第一段内容第二段内容" ← 段落边界丢失!
正确清洗: "第一段内容\n第二段内容" ← 保留了结构
在后续的分块步骤里,段落边界是判断"这里可以切一刀"的重要信号。如果你在这一步就把它丢了,后面的分块器就变成了瞎子。
接下来是Markdown清洗——这又是一个容易踩坑的地方:
export function cleanMarkdown(md: string, preserveStructure = true): string {
let text = md;
if (preserveStructure) {
// 保留标题文本(对后续语义分割有价值)
text = text
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1') // 粗体 → 纯文本
.replace(/\*(.+?)\*/g, '$1'); // 斜体 → 纯文本
}
return text
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [text](url) → text
.replace(/!\[.*?\]\([^)]+\)/g, '') // 移除图片语法
.replace(/`{1,3}[^`]*`{1,3}/g, '') // 移除行内/块级代码
.replace(/^>\s?/gm, '') // 引用标记
.replace(/^[-*+]\s/gm, '') // 无序列表标记
.replace(/^\d+\.\s/gm, ''); // 有序列表标记
}
这里的 preserveStructure 参数是一个关键决策点。Markdown的 # 标题 和 **粗体** 不是"格式噪音"——它们标记了文档的语义层次。标题告诉分块器"这里是一个新主题的开始",粗体告诉模型"这是作者想强调的重点"。如果你的清洗函数一刀切地把标题词和正文词混在一起,就等于主动丢弃了模型可以理解的结构信息。
我在真实项目中踩过的三个坑:
-
URL的有罪推定:很多教程教你把所有URL替换掉。但技术文档里的API链接、SDK文档里的GitHub链接,往往是回答用户问题时最需要的信息源。我的建议是:不要删除URL,而是提取链接文本,把URL放进metadata。
-
代码块的两难:对技术知识库来说,代码示例是核心内容;对通用知识库来说,代码块是纯浪费token的噪音。你需要根据业务场景决定是保留还是移除。我们的KMS项目因为是面向技术团队的,所以代码块保留但做了缩进去除处理。
-
页眉页脚的幽灵:从PDF/WORD解析出来的文本里,
第1页/共10页这种页码标记和页眉页脚无处不在。它们不仅浪费token,还会在检索时被当成正文内容匹配到——用户搜"验收流程"却匹配到了页脚里的"流程部"三个字。
四、Level 3:质量过滤(不是所有文本都值得被索引)
到了第三个层次,我们要开始行使"守门人"的职责——判断一段内容值不值得被检索引擎索引。
前端同学对"内容长度"天然敏感——毕竟UI里展示空字符串或者1个字的Tooltip不是什么大事。但在RAG中,太短的内容是纯粹的噪音:一个"确定"按钮的文案、一行</div>残渣、一个被分块器切碎的标点符号——这些东西塞进向量库后,检索时会被当成"相关结果"返回,挤占Top-K的宝贵位置。
export interface QualityCheck {
pass: boolean;
reason?: string;
score: number;
}
export function qualityFilter(text: string): QualityCheck {
const stripped = text.trim();
// 1. 长度检查——太短没价值,太长需要再切
if (stripped.length < 20) {
return { pass: false, reason: 'too_short', score: 0 };
}
if (stripped.length > 8000) {
return { pass: false, reason: 'too_long_may_need_chunking', score: 0.5 };
}
// 2. 空白占比——全空格的"幽灵段落"
const nonSpaceRatio = stripped.replace(/\s/g, '').length / stripped.length;
if (nonSpaceRatio < 0.3) {
return { pass: false, reason: 'too_much_whitespace', score: 0 };
}
// 3. 特殊字符占比——乱码/二进制残渣检测
const specialChars = stripped.match(
/[^\w\s一-鿿.,!?;:'"()()【】《》\- -〿-]/g
) || [];
if (specialChars.length / stripped.length > 0.15) {
return { pass: false, reason: 'too_many_special_chars', score: 0 };
}
// 4. URL占比——导航页/联系页识别
const urlMatches = stripped.match(/https?:\/\//g) || [];
if (urlMatches.length > 5 && stripped.length < 500) {
return { pass: false, reason: 'navigation_page', score: 0 };
}
return { pass: true, score: 1.0 };
}
这个过滤器的核心思路是多维度的质量打分。单靠"长度>10"这种一维判断太脆弱了——一段500字的导航页(全是链接)也会通过长度检查,但它对用户问题的回答能力是零。
这里我想强调一个很容易被忽略的指标:信息密度。一段文本可能很长,但如果它的"有意义内容"占比很低,它就是一个低质量片段。我的判断标准是:
- 空白占比 > 70% → 丢弃(几乎全是空格/换行)
- 特殊字符占比 > 15% → 丢弃(乱码或二进制数据)
- URL密集 + 总长短 → 丢弃(导航页,没有实质内容)
这些都是我们在KMS项目里从真实文档上跑出来的经验阈值。你的场景可能不同,但核心原则是一样的:宁可漏索引一篇文档,也不要让一百篇垃圾把检索结果污染了。
在质量过滤之上,还有一个重要的子主题:重复内容检测。我们不在这里展开——它会作为Level 4的MinHash去重单独讲,因为这是RAG专项清洗里最值得深入理解的技术。
五、Level 4:RAG专项清洗(从"为干净而干净"到"为检索效果而洗")
前三个层次,你的心态是"把脏东西洗掉"。到了第四个层次,心态要切换成"只做对检索质量有提升的清洗"。这是一个质的飞跃——不再为了清洗而清洗,而是每一刀下去都有明确的检索效果预期。
5.1 MinHash去重——为什么去重是RAG的生死线
先说一个场景,让你直观感受"不去重"的后果。
假设你的知识库里有8篇文档:
[DOC-1] React 18 引入了并发特性,包括 Suspense 和 startTransition API...
[DOC-2] React 18 带来了并发渲染能力,Suspense 组件和 startTransition 方法...
[DOC-3] React 18 新增并发模式,通过 Suspense 和 startTransition 实现...
[DOC-4] Vue 3 使用 Proxy 实现响应式系统,替代了 Vue 2 的 Object.defineProperty...
[DOC-5] TypeScript 5.0 引入了装饰器标准,与 ECMAScript 提案保持一致...
[DOC-6] React 18 的并发渲染让开发者可以使用 Suspense 处理异步数据加载...
[DOC-7] Vue 3 的 Composition API 让逻辑复用变得更简单,配合 Proxy 响应式系统...
[DOC-8] React 18 核心就是并发,Suspense 和 startTransition 是两个主要 API...
当用户查询"React 18 有什么新特性",不去重的情况下Top-3检索结果可能是:
1. [DOC-1] React 18 引入了并发特性...
2. [DOC-2] React 18 带来了并发渲染能力... ← 和1几乎一样
3. [DOC-3] React 18 新增并发模式... ← 还是同一个意思
DOC-4到DOC-8里关于Vue 3和TypeScript 5.0的有价值信息完全被挤掉了。LLM拿到了三段几乎一样的内容,不仅浪费了Token预算,还会因为"信息重复"产生认知偏差——它会以为这些重复观点就是全部事实。
去重 = 让Top-K的每个位置都物有所值。
那MinHash是怎么做到近似去重的?它背后的数学原理其实非常优雅。我们分三步理解:
Step 1:Shingling——把文本切成n-gram
文本A: "今天天气很好适合出门散步"
文本B: "今天天气很好适合出门跑步"
2-gram shingles:
A: [今天][天天][天气][气很][很好][好适][适合][合出][出门][门散][散步]
B: [今天][天天][天气][气很][很好][好适][适合][合出][出门][门跑][跑步]
交集 = {今天, 天天, 天气, 气很, 很好, 好适, 适合, 合出, 出门} ← 9个
并集 = 11 + 11 - 9 = 13个
Jaccard 相似度 = 9/13 ≈ 69%
Step 2:MinHash签名——用"最小哈希值"近似代表整个文档
核心直觉:如果两篇文档相似,它们共享很多shingle。现在用K个不同的哈希函数,每个函数对所有shingle算哈希值,只保留最小的那个。两篇文档的签名(各K个最小哈希值)中,位对位匹配的比例 = Jaccard相似度的无偏估计。
类比:两副扑克牌混入了部分相同的牌。你分别找出每副牌里"最小的那张"——如果两副牌相似,最小牌碰巧相同的概率就高。重复128次(用128种不同的"大小规则"),统计128次中有多少次最小牌相同,就得到了Jaccard近似值。
Step 3:用FNV-1a哈希实现
FNV-1a是一个简单但分布极均匀的哈希算法,比简单的乘法哈希好得多:

class MinHash {
private seeds: number[];
constructor(numHashes = 128) {
// 用质数种子保证哈希函数的独立性
const primes = [
2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53,
59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113,
127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181,
191, 193, 197, 199, 211, 223, 227, 229, 233, 239, 241, 251,
257, 263, 269, 271, 277, 281, 283, 293, 307, 311, 313, 317,
331, 337, 347, 349, 353, 359, 367, 373, 379, 383, 389, 397,
401, 409, 419, 421, 431, 433, 439, 443, 449, 457, 461, 463,
467, 479, 487, 491, 499, 503, 509, 521, 523, 541, 547, 557,
563, 569, 571, 577, 587, 593, 599, 601, 607, 613, 617, 619,
631, 641, 643, 647, 653, 659, 661, 673, 677, 683, 691, 701,
709, 719
];
this.seeds = primes.slice(0, numHashes);
}
// FNV-1a 哈希:简单但分布均匀
private hash(str: string, seed: number): number {
let h = (2166136261 ^ seed) >>> 0;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
// 计算 MinHash 签名
signature(text: string, n = 2): number[] {
const clean = text.replace(/\s+/g, '');
const shingles = new Set<string>();
for (let i = 0; i <= clean.length - n; i++) {
shingles.add(clean.slice(i, i + n));
}
if (shingles.size === 0) return this.seeds.map(() => 0xffffffff);
return this.seeds.map(seed => {
let minVal = 0xffffffff;
for (const s of shingles) {
const h = this.hash(s, seed);
if (h < minVal) minVal = h;
}
return minVal;
});
}
// 估算 Jaccard 相似度
similarity(sig1: number[], sig2: number[]): number {
let matches = 0;
for (let i = 0; i < sig1.length; i++) {
if (sig1[i] === sig2[i]) matches++;
}
return matches / sig1.length;
}
}
实战数据:用128个哈希函数、2-gram shingling,对前面那8篇文档做去重,阈值设为Jaccard相似度 > 30%:
DOC-1 <-> DOC-2 相似度 55% <<< 重复
DOC-1 <-> DOC-3 相似度 42% <<< 重复
DOC-2 <-> DOC-3 相似度 38% <<< 重复
...
去重效果: 8篇 → 5篇 (减少3篇)
React 18 相关: 5篇 → 2篇 (去重3篇)
节省 Embedding 调用: 3次
节省向量存储: 3条
中文场景的特殊注意事项:字符级的n-gram对中文的去重效果不如英文。因为英文单词天然有空格分隔,2-gram shingle就是两个单词的组合;但中文是连续字符流,"并发特性"和"并发渲染"这两个2-gram只有50%重叠——实际上是同一个意思的不同表述。对于中文paraphrase检测,字符级2-gram的Jaccard相似度通常只有30-50%,建议在生成环境引入分词(jieba)或者用Embedding相似度做二次确认。
5.2 PII数据脱敏——企业知识库的安全底线
如果你的知识库包含了客户沟通记录、内部报告或者CRM导出的数据,PII(个人身份信息)脱敏就是绕不开的硬性要求。
class RedactionStep implements CleaningStep {
name = "数据脱敏";
private patterns = [
{ regex: /\b1[3-9]\d{9}\b/g, replacement: "[手机号]", label: "手机号" },
{ regex: /\b\d{6}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b/g,
replacement: "[身份证]", label: "身份证" },
{ regex: /\b\d{16,19}\b/g, replacement: "[银行卡]", label: "银行卡" },
{ regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
replacement: "[邮箱]", label: "邮箱" },
];
process(chunks: Chunk[]): Chunk[] {
return chunks.map(chunk => {
let text = chunk.text;
let redactedCount = 0;
for (const { regex, replacement, label } of this.patterns) {
const matches = text.match(regex);
if (matches) {
redactedCount += matches.length;
text = text.replace(regex, replacement);
}
}
chunk.text = text;
chunk.meta.cleaningLog.push(
redactedCount > 0
? `[数据脱敏] 🔒 脱敏 ${redactedCount} 处敏感信息`
: `[数据脱敏] 无需脱敏`
);
return chunk;
});
}
}
说实话,正则脱敏只能覆盖常见的格式化敏感信息。如果涉及到中文姓名的识别、地址的模糊匹配或者医疗数据的脱敏,建议用专门的NER模型(如微软的Presidio)。正则在这里起的作用是"快速拦截80%的常见格式",剩下的20%需要更智能的方式处理。
5.3 Embedding感知清洗
这个子话题我想点到为止,因为它在生产环境里关联的因素太多了。核心原则是:移除那些会让Embedding模型产生"错误相似性"的内容。
比如,两段完全不相关的文本如果都包含了大量URL,它们的Embedding向量可能因为URL的token重叠而产生虚假的相似性。所以URL不仅要在Level 2清洗,更要在这个层次做规范化替换(把URL统一替换成 [URL] 占位符,而不是直接删除——保留"这里有一个链接"的语义信号)。
六、Level 5:生产级Pipeline(从"脚本"到"系统")
如果你只写一个清洗函数放在项目里,三个星期后你就会对着线上日志问自己:“这批文档到底被洗成什么样了?哪个步骤出了问题?”
第五个层次关心的不是清洗逻辑本身,而是清洗的可组合性、可观测性和容错性。
6.1 链式Pipeline模式
核心思想是把每个清洗步骤做成独立的 CleaningStep,然后通过链式调用组装Pipeline:
interface CleaningStep {
readonly name: string;
process(chunks: Chunk[]): Chunk[] | Promise<Chunk[]>;
}
class CleaningPipeline {
private steps: CleaningStep[] = [];
private stats: StepStats[] = [];
addStep(step: CleaningStep): this {
this.steps.push(step);
return this; // 链式调用
}
async run(chunks: Chunk[]): Promise<{
cleaned: Chunk[];
stats: StepStats[];
summary: string;
}> {
this.stats = [];
let result = chunks;
console.log("╔══════════════════════════════════════════════╗");
console.log("║ RAG 数据清洗 Pipeline 开始 ║");
console.log("╚══════════════════════════════════════════════╝\n");
console.log(`📥 输入: ${chunks.length} 条数据\n`);
for (const step of this.steps) {
const startTime = performance.now();
const inputCount = result.length;
try {
result = await step.process(result);
} catch (err) {
console.error(` ❌ ${step.name} 执行异常:`, err);
// 不中断Pipeline,当前步骤失败保持数据不变
}
const durationMs = Math.round(performance.now() - startTime);
const outputCount = result.length;
const stat: StepStats = {
name: step.name,
input: inputCount,
output: outputCount,
dropped: inputCount - outputCount,
durationMs,
};
this.stats.push(stat);
const icon = stat.dropped > 0 ? "🔽" : "➡️";
console.log(
` ${icon} ${step.name.padEnd(16)} ` +
`${stat.input}条 → ${stat.output}条 ` +
`(丢弃${stat.dropped}) · ${stat.durationMs}ms`
);
}
// 每条数据的清洗轨迹
console.log(`\n═══════════ 清洗轨迹详情 ═══════════\n`);
for (const chunk of result) {
console.log(`📄 ${chunk.id} (${chunk.meta.source})`);
for (const log of chunk.meta.cleaningLog) {
console.log(` ${log}`);
}
const preview = chunk.text.slice(0, 80).replace(/\n/g, '↵');
console.log(` 最终文本预览: "${preview}..."\n`);
}
const totalMs = this.stats.reduce((s, st) => s + st.durationMs, 0);
const summary = [
`总输入: ${chunks.length}条`,
`总输出: ${result.length}条`,
`总丢弃: ${chunks.length - result.length}条`,
`总耗时: ${totalMs}ms`,
].join(" | ");
console.log(`═══════════ 📊 汇总 ═══════════`);
console.log(` ${summary}\n`);
return { cleaned: result, stats: this.stats, summary };
}
}
使用起来非常直观:
const pipeline = new CleaningPipeline()
.addStep(new WhitespaceStep())
.addStep(new HtmlStripStep())
.addStep(new QualityFilterStep())
.addStep(new RedactionStep());
const { cleaned, stats } = await pipeline.run(rawChunks);
// stats 可直接用于监控面板
console.table(stats);
6.2 Pipeline设计的四个关键决策
第一,顺序很重要。 不是随便排列的。空白归一化必须放在一切其他清洗之前——因为后续步骤(如HTML标签匹配、质量过滤的长度计算)都依赖"空白已经归一化了"这个假设。在我的Pipeline里,顺序是:
WhitespaceStep → HtmlStripStep → QualityFilterStep → RedactionStep
这个顺序的考量是:先归一化空白让模式匹配准确 → 再剥离标签得到纯净文本 → 然后做质量判断丢弃不合格的 → 最后在"确定要入库"的文本上做脱敏(避免对已丢弃文本浪费脱敏操作)。
第二,每步独立日志。 注意每个 CleaningStep 的 process() 方法都会往 chunk.meta.cleaningLog 里追加记录。这样当线上某个文档"洗完后只剩标题"时,你能回溯到具体是哪一步出了问题——是HTML清洗把正文当标签删了?还是质量过滤的阈值设得太高?
第三,错误隔离。 注意 try-catch 包裹了每个步骤。任何一个步骤挂了,Pipeline不应该整车翻掉——而是记录错误,保持当前数据不变,继续下一步。这在生产环境至关重要,因为你永远不知道下一批导入的数据里藏着什么奇形怪状的格式。
第四,实时可观测性。 Pipeline跑完后返回的 stats 包含了每一步的输入/输出/丢弃数量/耗时。这个数据可以直接接入Grafana之类的监控面板,让你看到"这个星期新导入的文档,质量过滤丢弃率突然从5%飙升到了30%——是不是数据源出了什么问题?"
6.3 生产环境Checklist
在真正把Pipeline部署到生产之前,还有一些必须考虑的点:
| 关注点 | 为什么重要 | 实现建议 |
|---|---|---|
| 可观测性 | 线上清洗出问题无法排查 | 每个Step记录processing log + 统计信息 |
| 重处理能力 | 清洗逻辑升级后需重洗历史数据 | 保留原始文本,永远不覆盖源数据 |
| 增量更新 | 文档变更时只重洗变更部分 | 基于内容hash做变更检测,只洗diff |
| 降级策略 | 某个清洗步骤挂掉不应阻断整个Pipeline | try-catch + 保留原始文本降级 |
| 流式处理 | 大文档多时避免内存爆炸 | AsyncIterable + 批次处理 |
| Python桥接 | JS生态在某些清洗任务上不成熟 | 语言检测/PDF解析用Python子进程 |
七、源码对比:Dify vs KMS——两个生产级项目怎么做的
为了不让学习停留在"纸上谈兵",我并行阅读了两个真实RAG项目的源码:开源的Dify和我们在做的KMS。两者的清洗策略差异巨大,但各自都有值得学习的地方。
| 维度 | Dify | KMS |
|---|---|---|
| 清洗目的 | 提升Embedding质量,减少噪音 | 防止ES分析器报错(位置增量错误) |
| 清洗粒度 | 全文清洗,用户可配置规则 | 只清洗chunk标题,正文几乎不动 |
| 破坏性 | 保守(保留内容完整性) | 极激进(去除所有括号/标点/换行) |
| 架构模式 | 单类 + if-else分支,不是Chain | Pipeline模式但清洗不在Pipeline里 |
| 可配置化 | ✅ 用户可选择启用/禁用规则 | ❌ 硬编码,不可配置 |
| Unicode标准化 | ❌ 未做NFKC | ⚠️ 有康熙部首替换字典但未接入Pipeline |
| 去重 | ❌ 两个项目都没有 | ❌ 两个项目都没有 |
Dify的设计思路:为Embedding质量服务
Dify的清洗入口在 CleanProcessor.clean(),它提供三个可配置的清洗规则:remove_extra_spaces(默认启用,压缩多余空白)、remove_urls_emails(默认禁用,去除邮箱和链接)、remove_stopwords(定义了但未实现——是个幽灵规则!)。
Dify做对的地方:清洗目的的明确性——它在代码里直接写 "remove_extra_spaces" 而不是 "rule_1",每个规则的ID语义清晰。用户可以选择启用哪些规则,这让系统有了一定的灵活性。
Dify的问题:它用的是单类 + if-else分支模式,想增加新规则就要直接修改 clean() 方法。而且代码仓库里有5个 Unstructured Cleaner 的实现文件(unstructured_extra_whitespace_cleaner.py 等),但没有一个被实际导入使用——全部是死代码。
KMS的设计思路:为系统稳定性服务
KMS的清洗逻辑散落在5个以上的文件中,没有统一入口。但它的取舍其实很聪明:
- 标题激进洗:
clean_text()用正则把所有括号、标点、数学符号、换行符一次性移除。这不是为了检索质量,而是为了防止ElasticSearch分析器在处理标题时抛出位置增量错误——标题不是检索的主要内容载体,丢了一些信息也无所谓。 - 正文保守处理:正文几乎不做清洗,保留完整内容给Embedding模型。因为正文里的括号(比如
func(a, b))可能承载语义,不能随便删。
KMS还有一个"休眠宝藏"——一段349个条目的康熙部首替换字典。康熙部首字符(如 ⽤ U+2F26)和普通CJK字符(用 U+7528)肉眼看起来完全一样,但Unicode不同。不做替换的话,ES分词会把它们当成不同的token。可惜这段代码写好了但从未被接入Pipeline。
两个项目的共同短板
看完两个项目的源码,最大的感受是:它们都没有真正的Composable Pipeline(像我们Level 5那样每步独立的链式设计),都没有可观测性(你看不到每一步清洗前后的数据变化),都没有去重逻辑(文档直接索引,重复内容不检测)。
这就是为什么"自己造轮子理解原理"比"拿来就用"有价值——你知道问题在哪,才能做得比现成方案更好。

八、总结与预告
五个层次,从一行的 .trim() 到完整的企业级Pipeline。我们从一个TypeScript前端工程师的视角,把RAG数据清洗这件事从头到尾拆了一遍。
五级速查表
| Level | 名称 | 核心关注 | 典型技术 | 一句话 |
|---|---|---|---|---|
| L1 | 基础文本清洗 | 空白、换行、Unicode | String.replace + NFKC | 5分钟搞定,但90%的项目会漏 |
| L2 | 文档结构清洗 | HTML/Markdown标签 | 正则 + 结构保留 | 剥离格式外壳,保留语义骨架 |
| L3 | 质量过滤 | 长度/密度/重复 | 多维评分 + 阈值 | 不是所有文本都值得被索引 |
| L4 | RAG专项清洗 | 去重/脱敏/Embedding感知 | MinHash + PII正则 | 每刀下去都有检索效果预期 |
| L5 | 生产级Pipeline | 可组合/可观测/可容错 | Chain of Responsibility | 从脚本到系统的质的飞跃 |
三个最重要的经验
-
数据清洗决定了RAG的上限。无论模型多强,脏数据的向量检索结果就是脏的。Garbage In, Garbage Out不是口号,是每天在向量库里发生的事。
-
清洗的粒度要分层。不是"越干净越好"——Level 1的Unicode标准化应该对全部文本做,但Level 2的Markdown标记是否删除需要判断场景。标题和正文的清洗策略应该不同。
-
可观测性比清洗算法本身更值钱。一个带日志的黑盒清洗函数,不如一个每步可追踪的Pipeline。线上出了问题能定位到是"第三步质量过滤的阈值设错了"比"这个文档洗坏了"有价值一百倍。
下一篇预告
数据清洗完成之后,下一步是什么?文本分块(Chunking)。
你洗干净的文档可能有好几千字,而Embedding模型的输入窗口有限。怎么把长文本"切"成合适的小块,同时保证每块都是语义完整的?固定长度切片和语义切片各有什么优劣?重叠窗口到底开多大?中文的句子边界检测怎么做?
下一篇《RAG从入门到精通(二):文本分块——你的Embedding模型不傻,是你把它喂撑了》,我们继续拆。
本系列文章基于KMS知识管理平台真实生产经验,所有代码可在本地TypeScript环境直接运行。欢迎在评论区留下你的清洗踩坑经历——每个坑都是通往生产级的垫脚石。

3255

被折叠的 条评论
为什么被折叠?



