先从一个真实案例说起。某技术团队在搭建RAG系统时,遇到一个颇为棘手的问题:代码完全一致,运行结果却忽好忽坏,有时能准确命中目标,有时关键数据就这样悄然流失。

经过层层排查,问题根源竟然出在文档本身:PDF里的一张表格,在解析过程中被同时生成了两个版本——一份是结构清晰的表格数据,另一份则是被打散成文本的乱码信息。进行向量检索时,两个版本都被召回,大模型根本无法判断该信任哪一个,结果自然时而正确、时而出错。
这就是文档预处理不到位所引发的典型后果。那么,文档预处理究竟要实现哪些目标?概括来说,就是以下四点:
| 目标 | 说明 | 错误示例 |
|---|---|---|
| 格式统一 | 将多种格式的文档统一转换为纯文本 | PDF 中的表格被乱码化 |
| 内容干净 | 去除噪声字符、规范空白符 | 保留页眉页脚、乱码字符 |
| 语义完整 | 切分时不破坏语义边界 | 在句子中间截断 |
| 元数据保留 | 记录来源、页码等信息 | 回答时无法溯源 |
而根据AWS的实践指南,RAG文档处理所面临的挑战远比想象中复杂:缺少清晰的章节标题,导致内容上下文难以识别;术语不一致、缩写未定义,模型理解时容易产生偏差;重复内容和冗长描述不仅浪费token,还会干扰检索效果;PDF中的图片被截断,关键信息直接丢失;同一个术语在不同位置含义不同,回答的准确性自然难以保障。
RAG 文档处理的核心要求
整个处理流程可以归纳为三个步骤:加载→清洗→分割。看似简单,但每个环节都有其技术要点。
flowchart LRsubgraph 文档预处理流水线direction TBsubgraph Row1 [ ]direction LRA[1.文档加载
Loading] B[2.文本清洗
Cleaning]C[3.文本分割
Splitting]endsubgraph Row2 [ ]direction LRA1[统一文本格式
保留元数据]B1[去除噪声字符
规范空白换行]C1[语义边界切分
控制块大小]end A -.- A1B -.- B1C -.- C1end
具体而言,核心要求主要有三点:
1. 格式统一
PDF、Markdown、Word……不同格式的文档,必须首先转换成统一的纯文本格式,才能进入后续流程。转换后的结果大致如下:
// 统一后的文本格式示例
{
"content": "这是文档的纯文本内容...",
"metadata": {
"source": "technical_guide.pdf",
"page": 42,
"title": "第三章:核心概念"
}
}
2. 内容干净
页眉页脚、特殊字符、多余空白、乱码字符……这些对检索没有帮助的噪声信息,必须彻底清理干净。
| 噪声类型 | 示例 | 处理方式 |
|---|---|---|
| 页眉页脚 | "机密文档·第3页" | 正则匹配删除 |
| 特殊字符 | \u0000、\ufffd | 替换或删除 |
| 多余空白 | 连续空格、空行过多 | 规范化处理 |
| 乱码字符 | 编码错误导致 | 过滤或修复 |
3. 语义完整
在对文档进行切分时,务必确保每个片段在语义上相对完整。例如,“闭包是指函数能够记住并访问它的词法作用域”这句话,不能从“词法作”这个位置截断,否则上下文就断裂了。合理的切分应当以段落、句子为自然边界。
// ❌ 错误:在语义边界外截断
const badChunk = "闭包是指函数能够记住并访问它的词法作"; // 句子被截断
// ✅ 正确:保持语义完整
const goodChunk = "闭包是指函数能够记住并访问它的词法作用域,即使这个函数在它的词法作用域之外执行。";
常用文档加载器使用详解
在LangChain中,前端开发者最熟悉的几种文档格式,加载起来并不复杂。
| 格式 | 加载器 | 前端应用场景 |
|---|---|---|
| TXT | TextLoader | 日志文件、配置文件 |
| Markdown | UnstructuredMarkdownLoader | 技术文档、README |
| CSV | CSVLoader | 数据导出、报表 |
PDFLoader | 用户手册、合同文档 |
实际开发时,可以编写一个函数,根据文件扩展名自动匹配合适的加载器,这样调用起来更加高效便捷。
// src/loaders/index.ts
import { TextLoader } from "@langchain/classic/document_loaders/fs/text"; // txt
import { CSVLoader } from "@langchain/community/document_loaders/fs/csv"; // csv
import { UnstructuredLoader } from "@langchain/community/document_loaders/fs/unstructured"; // md / 通用
// 1. 加载 TXT 文件
async function loadTxtFile(filePath: string) {
const loader = new TextLoader(filePath);
const docs = await loader.load();
return docs;
}
// 2. 加载 Markdown 文件(保留标题结构)
async function loadMarkdownFile(filePath: string) {
const loader = new UnstructuredLoader(filePath);
const docs = await loader.load();
// Markdown 的标题层级会被保留在 metadata 中
return docs;
}
// 3. 加载 CSV 文件
async function loadCsvFile(filePath: string) {
const loader = new CSVLoader(filePath);
const docs = await loader.load();
// 每行 CSV 变成一个 Document,列名存入 metadata
return docs;
}
// 4. 根据文件扩展名自动选择加载器
export async function loadDocument(filePath: string) {
const ext = filePath.split('.').pop()?.toLowerCase();
switch (ext) {
case 'txt':
return loadTxtFile(filePath);
case 'md':
return loadMarkdownFile(filePath);
case 'csv':
return loadCsvFile(filePath);
default:
throw new Error(`不支持的文件格式: ${ext}`);
}
}
这里有一个容易被忽略的关键点:加载器会自动提取文档的元数据。例如来源文件名、PDF的页码、TXT的行号、Markdown的标题等。这些元数据是后续溯源的重要依据,务必妥善保留。
// 加载后的 Document 结构示例
{
pageContent: "文档的实际文本内容...",
metadata: {
source: "technical_guide.pdf", // 来源文件
page: 42, // 页码(PDF)
line: 15, // 行号(TXT)
title: "核心概念" // 标题(Markdown)
}
}
文本清洗与预处理方案
在文本清洗环节,每个项目遇到的“脏数据”类型可能各不相同,但需要处理的主要类别大致有以下几种:
| 清洗内容 | 优化前 | 优化后 |
|---|---|---|
| 特殊字符 | function test(){console.log("hello")} | function test(){console.log("hello")} |
| 多余空白 | "闭包是Ja vaScript的核心" | "闭包是 Ja vaScript 的核心" |
| 不换行空格 | hellou00A0world | hello world |
| 控制字符 | Hellou0000World | HelloWorld |
| 编码问题 | effected | effected |
下面是一个功能较为完备的清洗函数实现,可以应对绝大多数常见场景:
// src/cleaners/text-cleaner.ts
interface CleanOptions {
removeSpecialChars?: boolean; // 移除特殊控制字符
normalizeWhitespace?: boolean; // 规范化空白字符
removeEmptyLines?: boolean; // 移除空行
trimLines?: boolean; // 每行首尾去空格
maxLineLength?: number; // 单行最大长度
}
const defaultOptions: CleanOptions = {
removeSpecialChars: true,
normalizeWhitespace: true,
removeEmptyLines: true,
trimLines: true,
maxLineLength: 1000,
};
/**
* 清洗文本内容
* @param text 原始文本
* @param options 清洗选项
* @returns 清洗后的文本
*/
export function cleanText(text: string, options: CleanOptions = defaultOptions): string {
let cleaned = text;
// 1. 移除特殊控制字符(保留换行和制表符)
if (options.removeSpecialChars) {
cleaned = cleaned.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
// 替换不换行空格为普通空格
cleaned = cleaned.replace(/\u00A0/g, ' ');
// 替换零宽字符
cleaned = cleaned.replace(/[\u200B-\u200D\uFEFF]/g, '');
}
// 2. 规范化空白字符
if (options.normalizeWhitespace) {
// 连续空格 → 单个空格
cleaned = cleaned.replace(/[ \t]+/g, ' ');
// 连续换行 → 最多两个
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
}
// 3. 每行首尾去空格
if (options.trimLines) {
cleaned = cleaned.split('\n').map(line => line.trim()).join('\n');
}
// 4. 移除空行
if (options.removeEmptyLines) {
cleaned = cleaned.split('\n').filter(line => line.length > 0).join('\n');
}
// 5. 截断过长的行
if (options.maxLineLength) {
cleaned = cleaned.split('\n').map(line =>
line.length > (options.maxLineLength as number)
? line.slice(0, options.maxLineLength) + '...'
: line
).join('\n');
}
return cleaned;
}
/**
* 批量清洗文档数组
*/
export function cleanDocuments(docs: any[], options?: CleanOptions): any[] {
return docs.map(doc => ({
...doc,
pageContent: cleanText(doc.pageContent, options),
}));
}
前端实现文档批量上传与读取
对于前端应用来说,RAG流程的起点就是用户上传文档。这里通过一个配合File API即可实现。下面是一个功能较为完整的Vue组件,支持多文件上传、状态管理(待处理、处理中、已完成、失败)以及简单的模拟清洗操作。
// src/components/DocumentUpload.vue
